System Design

System Design Adventure-14

Database Indexing: How Databases Find Data Fast

Posted by Afsal on 02 Oct 2026

Hi Pythonistas!

You have a users table.
100 million rows.

SELECT * FROM users WHERE email = 'afsal@example.com';

Without any special structure:database starts at row 1.Checks every row.
Is this the email? No. Next. No. Next...100 million times.

This is called a full table scan. Takes seconds. Maybe minutes.

With an index

database jumps directly to the row.1 lookup.Microseconds. That's what indexing does.

What Is an Index?

A separate data structure.Maintained alongside your table.

Contains:
the indexed column values.
pointers to the actual rows.
Index on email:

email                 | row pointer
----------------------|------------
afsal@example.com     | → row 1
arjun@example.com     | → row 45
priya@example.com     | → row 892
rahul@example.com     | → row 2341
  • Sorted.
  • Searchable in O(log n) Instead of O(n) full scan.

The Real Data Structure - B-Tree

Most database indexes use a B-Tree.

               [M]
               /   \
        [D, H]       [R, V]
       /  |  \       /  |  \
     [A] [E] [J]  [N] [S] [W]
  • Each node holds multiple keys.
  • Sorted order maintained.
  • All leaf nodes at same depth.

Why B-Tree?

Databases store data on disk.
Disk reads are slow.
B-Tree minimizes disk reads.
Each node = one disk page (8KB or 16KB).
One read → many keys checked.

How B-Tree Search Works

Find email = 'priya@example.com':
Start at root: [M]
priya > M → go right → [R, V]
priya < R → go left  → [N]
priya < N → go left  → leaf: [priya → row 892]
Found. Jump to row 892.
Instead of scanning 100 million rows:
3-4 lookups.
O(log n).
log2(100,000,000) ≈ 27 steps
vs
100,000,000 steps without index

Creating an Index

-- single column
CREATE INDEX idx_users_email ON users(email);

-- multiple columns
CREATE INDEX idx_name_email ON users(name, email);

-- unique index
CREATE UNIQUE INDEX idx_email ON users(email);

-- partial index
CREATE INDEX idx_active ON users(email)
WHERE active = true;
PostgreSQL automatically creates indexes for:


PRIMARY KEY → always indexed
UNIQUE      → always indexed
Everything else → you decide.

Types of Indexes

Single Column

CREATE INDEX ON users(email);

WHERE email = 'afsal@...'  → uses index ✅
WHERE name = 'Afsal'       → does NOT use index ❌

Composite

Index on multiple columns.

CREATE INDEX ON users(name, email);

Order matters.Leftmost prefix rule: Index usable from leftmost column only.

WHERE name = 'Afsal'              → uses index ✅
WHERE name = 'Afsal' AND email... → uses index ✅
WHERE email = 'afsal@...'         → does NOT use index ❌

Covering

Index contains all columns the query needs.

No need to touch the actual table.

CREATE INDEX ON users(email, name, created_at);

SELECT name, created_at FROM users WHERE email = 'afsal@...';

-- all columns in index

-- never touches table

-- extremely fast ✅

Partial

Index only a subset of rows.

CREATE INDEX ON users(email) WHERE active = true;

Smaller index.

Faster for queries on active users.

Full-Text

For searching inside text content.

CREATE INDEX ON posts USING GIN(to_tsvector('english', content));

SELECT * FROM posts

WHERE to_tsvector('english', content) @@ to_tsquery('system & design');

Inverted index under the hood.

Maps words → documents containing them.

Index Type Selection

Situation                        Index Type
─────────────────────────────────────────────
Default / most cases             B-Tree
Equality + range + sort          B-Tree
Exact equality only              Hash
Full-text search                 GIN
Array contains                   GIN
JSONB column queries             GIN
Geographic queries               GiST

The Cost of Indexes

Indexes are not free.

Write slowdown

INSERT a new user:
1. Write to users table
2. Update email index
3. Update name index
4. Update created_at index
More indexes = slower writes.

Storage

Index is a separate data structure.
Takes disk space.
Large table with many indexes:
index size can equal table size.

Memory

Active indexes loaded into memory.
Too many indexes = memory pressure.
More disk reads.
Slower overall.
More indexes  → faster reads, slower writes, more storage
Fewer indexes → slower reads, faster writes, less storage

Selectivity

Not all indexes are equally useful.
Selectivity = how unique are the values?
High selectivity → good index:
email    → almost every value unique ✅
user_id  → every value unique        ✅

Low selectivity → poor index:
gender   → only 2-3 values           ❌
active   → only true/false           ❌
country  → ~200 values               ⚠️

EXPLAIN - See If Index Is Used

EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'afsal@example.com';

#Without index:

Seq Scan on users
  Filter: (email = 'afsal@example.com')
  Rows Removed by Filter: 99999999
Seq Scan = full table scan. Bad.

#With index:

Index Scan using idx_users_email on users
  Index Cond: (email = 'afsal@example.com')
Index Scan = index used. Good.


What to look for

Good signs:
Index Scan       → index used ✅
Index Only Scan  → covering index ✅ (fastest)
Bitmap Index Scan → multiple indexes combined ✅

Bad signs:
Seq Scan         → full table scan ❌
rows=10000000    → scanning too many rows ❌
Filter: ...      → filtering after scan ❌

Common Mistakes and Fixes

Mistake                          Fix
──────────────────────────────────────────────────────────
Too many indexes                 Only index queried columns
                                 Remove unused indexes

Function on indexed column       Function-based index
WHERE LOWER(email) = '...'      CREATE INDEX ON users(LOWER(email))

Type mismatch                    Match types in query
WHERE phone = 9876543210        WHERE phone = '9876543210'
(phone is VARCHAR)

Wrong composite order            Most selective column first
(status, user_id)               Use (user_id, status)

Low cardinality alone            Combine with high cardinality
WHERE active = true             (user_id, active) or partial index

Missing FK index                 Always index foreign keys
posts.user_id → users.id        CREATE INDEX ON posts(user_id)

Query Pattern → Index Pattern

Query Pattern                             Index to Create
──────────────────────────────────────────────────────────────
WHERE email = '...'                       (email)
WHERE user_id = 1 ORDER BY created_at     (user_id, created_at)
WHERE status = 'active' AND user_id = 1   (user_id, status)
WHERE name LIKE 'Afsal%'                  (name) B-Tree works
WHERE name LIKE '%afsal%'                 Full-text GIN
WHERE tags @> ARRAY['python']             GIN on tags
WHERE data->>'city' = 'Kerala'            GIN on JSONB
SELECT name,email WHERE email = '...'     (email, name) covering

Production Index Creation

sql
-- NEVER do this in production (locks table)
CREATE INDEX ON users(email);

-- ALWAYS do this in production (no lock)
CREATE INDEX CONCURRENTLY ON users(email);

CONCURRENTLY takes longer. But doesn't block reads or writes. Critical in production.

Index Maintenance

-- find slow queries
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

-- find unused indexes
SELECT indexname
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE 'pg_%';

-- rebuild bloated index
REINDEX INDEX CONCURRENTLY idx_users_email;

-- check index usage
SELECT indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
WHERE tablename = 'users';

The 5-Step Indexing Process

Step 1: Find slow queries
        pg_stat_statements → sort by mean_exec_time

Step 2: Run EXPLAIN ANALYZE
        Seq Scan? Needs index.

Step 3: Identify the right index
        What column in WHERE/JOIN/ORDER BY?
        High selectivity?
        Single or composite?

Step 4: Create the index
        CREATE INDEX CONCURRENTLY
        (doesn't lock table)

Step 5: Verify and monitor
        Run EXPLAIN again
        Index Scan now?
        Check write performance

Quick Reference

Fast lookup by email/id?         → Single column index
Fast lookup by user + date?      → Composite (user_id, date)
All query columns in index?      → Covering index
Search text content?             → GIN full-text index
Query arrays or JSONB?           → GIN index
Geo queries?                     → GiST index
Index subset of rows?            → Partial index
Query still slow after index?    → Run EXPLAIN ANALYZE
                                   Check leftmost prefix rule
                                   Check type mismatch
                                   Check function on column

Mental Model

Index         → separate structure for fast lookup
Full scan     → check every row, O(n), slow
Index scan    → jump directly, O(log n), fast
B-Tree        → default, works for most cases
Composite     → multiple columns, leftmost rule
Covering      → all columns in index, fastest
Partial       → index subset of rows
Selectivity   → high cardinality = good index
EXPLAIN       → shows if index used
Write cost    → every write updates all indexes
CONCURRENTLY  → create index without locking table
GIN           → full-text, arrays, JSONB
GiST          → geographic data

What Changed for Me

Before this:
I added indexes randomly.Hoping they'd help.

After this:
I look at the query first.
Understand the access pattern.
Choose the right index type.
Verify with EXPLAIN.
Monitor write performance.
Indexing is not guessing.
It's a systematic process.

What's Coming Next

Now you know how databases store and find data.
But what about data you don't structure at all?
Images. Videos. PDFs. Audio files.
How do systems store and serve billions of files?
Object Storage and Blob Storage.
How S3 works.
How files travel from your phone to a server
and back to someone else's screen.

← Previous Post

Recent posts