system-design · beginner

Database Indexes — Find Rows Without Scanning Everything

The Central Question

Consider a production orders table in an e-commerce platform holding 50,000,000 purchase records.

A user opens their mobile application, triggering the following backend API request:

SELECT order_id, total_amount, created_at
FROM orders
WHERE user_id = 89041
ORDER BY created_at DESC
LIMIT 20;

Without an auxiliary search structure, the database engine must execute a Sequential Table Scan (Full Table Scan). It reads all 50,000,000 rows from physical storage into RAM memory, evaluates user_id = 89041 for every single row, sorts the matching subset in memory, and returns 20 rows.

At 50,000,000 rows, a sequential scan consumes 100% database CPU, reads gigabytes of disk I/O, and takes 8 to 15 seconds to execute—causing severe API timeouts.

To transform expensive full table scans into sub-millisecond lookups, databases use Indexes.

A Database Index is a secondary, self-balancing search data structure (most commonly a B-Tree) maintained alongside table data to locate matching rows quickly without reading un-related rows.

This lesson answers one central question: How do B-Tree and Hash database indexes transform $O(N)$ full table scans into $O(\log N)$ or $O(1)$ disk lookups, and how do engineers design multi-column composite indexes while balancing read acceleration against write amplification penalties?


Scan vs. Seek: Sequential Scans vs. B-Tree Index Traversal

The performance difference between un-indexed and indexed queries maps directly to algorithmic time complexity:

flowchart TB
  subgraph Sequential Scan: O(N) Un-indexed Search
    Q1[Query: WHERE user_id = 89041] --> ReadAll[Read 50,000,000 Rows sequentially from disk heap]
    ReadAll --> Filter[Evaluate user_id for every row in memory]
    Filter --> Time1[Execution Time: 12,000 ms]
  end
  subgraph B-Tree Index Seek: O(log N) Indexed Search
    Q2[Query: WHERE user_id = 89041] --> Traverse[Traverse B-Tree Root -> Branch -> Leaf]
    Traverse --> DirectRead[Read exact 20 Heap Tuples via disk pointers]
    DirectRead --> Time2[Execution Time: 1.5 ms]
  end

Figure 1: Algorithmic contrast between an $O(N)$ sequential table scan and an $O(\log N)$ B-Tree index lookup.

Algorithmic Mathematical Comparison

Query Access MethodTime ComplexityDisk Pages Read (50M Rows)Typical Latency
Sequential Table Scan$O(N)$ Linear Search500,000 Data Pages8,000 ms – 15,000 ms
B-Tree Index Seek$O(\log_B N)$ Tree Traversal3 to 5 Index Pages + Heap Fetches1 ms – 3 ms

Where $B$ is the B-Tree fan-out factor (typically $B \approx 100$ to $500$ entries per node page). A B-Tree indexing 50,000,000 rows requires only 3 to 4 pointer hops from root to leaf!


B-Tree Anatomy: How Indexes Work on Disk

The B-Tree (Balanced Tree) is the standard index data structure in PostgreSQL, MySQL InnoDB, and Oracle. It keeps indexed keys in sorted order across fixed-size disk page nodes:

flowchart TD
  Root["Root Node Page [Keys: 250, 750]"] --> B1["Branch Node Page [Keys: 50, 100, 200]"]
  Root --> B2["Branch Node Page [Keys: 300, 500, 700]"]
  
  B1 --> L1["Leaf Page 1: Key 50 -> Pointer Tuple #102"]
  B1 --> L2["Leaf Page 2: Key 100 -> Pointer Tuple #891"]
  
  Note1["Leaf Node Pages form a doubly linked list for fast range scans."]

Figure 2: Architectural anatomy of a B-Tree index structure showing Root, Branch, and Leaf pointer nodes.

B-Tree Leaf Page Doubly-Linked Range Scans

In a B-Tree index:
  1. Root and Branch Nodes: Direct search traversals downward to the correct leaf page based on key comparisons.
  2. Leaf Nodes: Contain the actual indexed column values alongside Tuple ID (TID) Disk Pointers targeting the underlying table heap.
  3. Doubly Linked Leaf Chain: All leaf pages link sequentially to their adjacent neighbors. This enables ultra-fast range queries (WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31') without re-traversing the tree from the root.

Composite Indexes and the Left-Prefix Rule

A Composite Index indexes multiple columns together in a specific, defined order, such as (user_id, status, created_at DESC).

The order of columns defined in a composite index dictates which queries can utilize the index based on the Left-Prefix Rule:

flowchart TD
  IndexDef["Composite Index: (user_id, status, created_at)"] --> Q1{"WHERE user_id = 89041?"}
  Q1 -->|Uses Left-Prefix| Match1[Fully Indexed - Fast Seek]
  
  IndexDef --> Q2{"WHERE user_id = 89041 AND status = 'COMPLETED'?"}
  Q2 -->|Uses Left-Prefix| Match2[Fully Indexed - Fast Seek]
  
  IndexDef --> Q3{"WHERE status = 'COMPLETED'?"}
  Q3 -->|Violates Left-Prefix!| Miss[CANNOT use Index! Full Table Scan]

Figure 3: Operational flow chart illustrating the Composite Index Left-Prefix Rule.

Left-Prefix Rule Summary

An index on `(A, B, C)` can accelerate queries filtering on: It **cannot** accelerate queries filtering solely on `WHERE B = ?` or `WHERE C = ?` because the index entries are sorted primarily by `A` first!

The Hidden Cost: Write Amplification

While indexes accelerate read queries, they impose a direct write penalty on every INSERT, UPDATE, and DELETE operation known as Write Amplification:

flowchart LR
  App[Application INSERT Statement] --> TableHeap[Write 1 Row to Table Heap]
  App --> Idx1[Update Primary Key B-Tree]
  App --> Idx2[Update Index 2: user_id B-Tree]
  App --> Idx3[Update Index 3: status B-Tree]
  App --> Idx4[Update Index 4: email B-Tree]

Figure 4: Write amplification illustrating one table insert updating four separate disk index structures.

The Write Amplification Trade-off

Adding 10 secondary indexes to a single high-throughput table means every single row insert forces the database engine to perform **11 distinct disk updates** (1 table heap write + 10 index page writes).

Over-indexing write-heavy tables degrades insert QPS, increases CPU lock contention, and inflates disk storage consumption.


Complete Worked Example: PostgreSQL EXPLAIN ANALYZE Optimization

Let's inspect a real-world optimization walkthrough for the CheckoutLab platform (checkoutlab.com).

1. Un-Indexed Slow Query Output

EXPLAIN ANALYZE 
SELECT * FROM orders 
WHERE user_id = 89041 
ORDER BY created_at DESC 
LIMIT 20;

-- QUERY PLAN:
-- Limit (cost=125430.12..125430.17 rows=20 width=142) (actual time=8412.110..8412.125 rows=20 loops=1)
-- -> Sort (cost=125430.12..125980.40 rows=22011 width=142)
-- Sort Key: created_at DESC
-- -> Seq Scan on orders (cost=0.00..124102.00 rows=22011 width=142)
-- Filter: (user_id = 89041)
-- Execution Time: 8415.420 ms

2. Creating the Composite Covering Index

-- Create Composite Index CONCURRENTLY to avoid blocking production table writes
CREATE INDEX CONCURRENTLY idx_orders_user_created 
ON orders (user_id, created_at DESC);

3. Optimized Indexed Query Output

EXPLAIN ANALYZE 
SELECT * FROM orders 
WHERE user_id = 89041 
ORDER BY created_at DESC 
LIMIT 20;

-- QUERY PLAN:
-- Limit (cost=0.43..12.45 rows=20 width=142) (actual time=0.045..0.112 rows=20 loops=1)
-- -> Index Scan using idx_orders_user_created on orders (cost=0.43..13241.12 rows=22011 width=142)
-- Index Cond: (user_id = 89041)
-- Execution Time: 0.145 ms

Execution time dropped from 8,415 ms down to 0.145 ms (a 58,000x speedup)!


Failure Modes and Engineering Mitigations

Failure ScenarioRoot CauseSystem SymptomDetection MetricMitigation Strategy
1. Un-Indexed Foreign KeysOmitting secondary indexes on foreign key columns used in joins.High CPU usage during multi-table joins; table locks on DELETE cascades.EXPLAIN showing Seq Scan on joined tables.Create B-Tree indexes on all foreign key columns referenced in join conditions.
2. Low Selectivity Index TrapCreating a secondary index on a boolean or low-cardinality column (is_active).Database planner ignores index and runs sequential scan anyway.Slow query log shows index is ignored by planner.Omit low-cardinality indexes; use Partial Indexes (CREATE INDEX ... WHERE is_active = true).
3. Blocking Production Index BuildRunning standard CREATE INDEX on a 100GB production table without CONCURRENTLY.Table write lock acquired; all API write operations block and fail.Spikes in blocked queries and connection timeouts during migration.Always use CREATE INDEX CONCURRENTLY in PostgreSQL (or ONLINE = ON in MySQL/SQL Server).
4. Unused Index AccumulationRetaining legacy secondary indexes after query access patterns change.Write latency degrades; disk storage space inflates with dead index bloat.High index disk footprint metrics in pg_stat_user_indexes.Audit index usage metrics periodically and drop un-used secondary indexes.

What You Should Remember

  1. Indexes trade write cost for read speed: Indexes use B-Trees to transform $O(N)$ sequential scans into $O(\log N)$ disk seeks.
  2. Left-Prefix Rule governs composite indexes: A composite index on (A, B, C) only accelerates queries that filter on leading column prefixes (A, A+B, A+B+C).
  3. Beware Write Amplification: Every secondary index forces an additional disk write on inserts, updates, and deletes.
  4. Use CONCURRENTLY for production migrations: Standard index creation acquires table write locks; always build indexes concurrently in production.
  5. Verify query plans with EXPLAIN ANALYZE: Never guess whether an index helps; inspect execution plans to confirm index scans.

Index Maintenance & Vacuuming Mechanics

In relational engines using Multi-Version Concurrency Control (MVCC) like PostgreSQL, updating a table row inserts a new tuple version and marks the old tuple version as dead. Secondary B-Tree indexes continue pointing to both live and dead tuple versions until a background maintenance process (PostgreSQL `VACUUM` or `REINDEX`) cleans up dead index entries. Over time, high-update tables suffer from **Index Bloat**, where un-cleaned dead index pages inflate disk storage footprint and degrade B-Tree traversal performance. Infrastructure engineers monitor index bloat metrics (`pgstatindex`) and schedule periodic concurrent re-indexing jobs (`REINDEX INDEX CONCURRENTLY`) to reclaim storage and restore optimal query seek speeds.

Partial Indexes and Expression Indexes

To minimize write amplification overhead, modern database engines support **Partial Indexes** and **Expression Indexes**. A Partial Index indexes only a subset of table rows that satisfy a specific `WHERE` predicate (e.g. `CREATE INDEX idx_unprocessed ON orders (created_at) WHERE status = 'PENDING'`). By indexing only active pending orders (0.1% of the table) rather than historical completed orders (99.9% of the table), partial indexes reduce index disk size by 99% while accelerating pending order queries. Similarly, Expression Indexes store computed values (e.g. `LOWER(email)`), allowing queries filtering on functions to utilize $O(\log N)$ B-Tree seeks. Using partial indexes significantly reduces write amplification penalties on high-throughput database tables while retaining fast lookups for active pending items. Partial indexes avoid indexing millions of historical completed records that are rarely queried by daily applications. This optimization preserves RAM buffer pool space for hot active working sets and ensures optimal cache hit ratios.

Glossary of Terms

TermDefinition
Sequential Scan (Table Scan)Reading every page of a database table sequentially from disk to find matching rows.
Index SeekTraversing an index B-Tree to locate specific matching row pointers directly.
B-Tree IndexA self-balancing search tree data structure that maintains sorted keys for $O(\log N)$ lookups.
Composite IndexAn index constructed across multiple columns in a specific defined order.
Left-Prefix RuleThe rule stating that a composite index can only be used by queries filtering on leading column combinations.
Write AmplificationThe additional write overhead incurred when maintaining secondary index structures during table updates.
Covering IndexAn index that contains all columns required by a query, allowing the database to return results without visiting the table heap.

Practice Scenario and Self-Assessment

Architecture Scenario

You are optimizing a messaging database containing a `messages` table with 100,000,000 rows:
CREATE TABLE messages (
    id BIGSERIAL PRIMARY KEY,
    sender_id BIGINT NOT NULL,
    recipient_id BIGINT NOT NULL,
    created_at TIMESTAMP NOT NULL,
    body TEXT
);

The application executes two frequent queries:


Questions:
  1. Design the minimal set of composite indexes to optimize both queries without creating redundant index write amplification.
  2. Explain why creating an index on (created_at, recipient_id) fails to optimize Query 1 under the Left-Prefix Rule.


Interactive Self-Assessment

It violates the Left-Prefix Rule because the query omits the leading column (user_id).

Composite indexes only support integer columns.

B-Tree indexes cannot execute equality filter comparisons.

The query must explicitly specify the index name in the FROM clause.

It acquires an exclusive table write lock for the duration of the build, blocking all incoming application writes.

It deletes all primary key constraints on the target table.

It converts the database network protocol from TCP to UDP.

It automatically drops all other existing secondary indexes on the table.


What to Learn Next

Track: Data, Storage and Messaging

Previous: Database Storage Architectures — B-Trees vs. LSM-Trees

Next: Database Sharding — Split Data Across Many Machines

By Shubham Jain

All articles · Study paths

Shubham Jain · Learning Lab