System Design Data
Database Indexing
Friday 6:12 PM: Uber shows Recent rides and asks for that rider's last twenty trips. Without help, the database would walk millions of ride rows one by one. With the right , it jumps straight to the matching keys and returns in milliseconds.
Indexing means matching a lookup structure to the filters and ordering the application actually uses. A useful index lets the database read less data for an important query, but adds storage and maintenance to writes that affect it. This lesson connects the query plan to the underlying structure, then checks whether the read benefit justifies the cost under the real workload.
Recent-rides journey
Hot query
WHERE user_id = ? ORDER BY created_at DESC LIMIT 20: the statement the screen fires most.
Lead with the equality column, then the sort column, so leaves come out pre-ordered.
Confirm the planner chose an index scan rather than walking the whole table.
Each new index is updated on every insert, so faster reads are never free.
What actually happens
The rider sees one list. Underneath, the query planner (the part of the database that decides how to run a statement) chooses between a sequential scan, which reads every row of the table, and an lookup. The usual index is a : the engine walks a few branch pages, lands on the leaf pages that hold the matching keys, and follows pointers to the rows. If the index also stores every column the query asks for, it is a and the engine never needs to visit the table at all.
Good design therefore starts from the exact WHERE, JOIN, ORDER BY, and LIMIT clauses that dominate traffic, not from the table's column list. Columns with high (an equality filter that leaves very few rows, such as a user id) usually lead a . Sort and range columns follow, so the engine can walk the leaves in exactly the order the UI needs and stop after twenty rows.
The catch is the write side. Every extra index is one more structure that each insert, update, and delete must touch, on top of the table page, the primary key, the WAL (Write-Ahead Log, the durability journal) and every replica. That is why you confirm a design with , watch which indexes actually get scanned, and drop the ones that never earn their write cost. The rest of this page walks that loop: choose, build, measure, and prune.
How to choose an index
Given that loop, three questions decide whether an index is worth building at all, and which shape it should take.
1. Which query is actually slow? Name the statement, its p95 latency (the time the slowest 5% of calls exceed), and how many rows qualify after each predicate before inventing columns to index.
2. Can a smaller structure win? Prefer a for hot subsets, or a for the top few read paths, instead of indexing every column "just in case." Smaller indexes stay in memory and cost less to maintain.
3. What does the write path pay? If the table is insert-heavy, measure and consider an store for append-only streams rather than stacking more B-tree secondaries on a table that is mostly written.
Simple rule of thumb
Start from hot queries, build the narrowest that matches filter and sort order, verify with , then audit unused indexes. In simple terms: index what you measure, not what you fear.
From query to index design
The three questions above become a repeatable procedure. The flow below shows where each design input comes from and why the loop only closes after you have looked at a real plan.
Hot query and SLO
Filter predicates
Sort and pagination
Columns returned
From filter + sort
Composite column order
From projection
Covering or INCLUDE columns
EXPLAIN ANALYZE on production-like data
Plan acceptable?
Yes
Monitor scans, bloat, cache hits
No
Refine index or rewrite query
Loop back to EXPLAIN ANALYZE
Reading the flow top to bottom: the SLO (service-level objective, the latency you promised) and the hot query feed three inputs. The filter predicates tell you which equality columns should lead, because a high-cardinality equality (many distinct values, such as user id) narrows the search fastest. The sort and pagination clause tells you which column comes next, and in which direction, so the index leaves already sit in the order the screen wants. The columns returned (the projection) tell you whether adding a few INCLUDE columns would make the index covering and skip the table entirely.
The loop closes on cost. Every extra index adds , more WAL bytes, cache pressure, and rebuild work, so a plan that looks fast in EXPLAIN still has to justify itself against the write path. The Numbers to know card further down puts figures on that bill.
Mechanism: B+ trees and LSM trees
The flow assumed an index that can jump to a key and then walk forward in order. This card explains the two storage structures that make that possible, starting with the one behind almost every relational index.
B+ tree internals
A B+ tree keeps keys sorted and groups many keys into each page. Internal pages narrow the search to a child range; leaf pages locate matching rows and support continuing through nearby keys. Because one page contains many keys, the tree can remain shallow even for a large table. The practical cost depends on which pages are already cached and how many table pages the result requires, not only the logarithmic lookup complexity.
The is the dominant index structure in relational databases built for OLTP (online transaction processing: many small reads and writes from live users). Every internal node acts only as a routing guide; all actual data pointers live in leaf nodes, and the leaves are linked to their neighbours so a range scan can move sideways without climbing back up the tree.
Key properties
- All data pointers live in leaf nodes only, so internal pages stay small and cache well.
- Leaf sibling pointers let range scans continue without revisiting any internal node.
- Balanced: every root-to-leaf path has identical length, giving O(log_B n) lookups.
- Page size is often 8KB to 16KB, depending on engine settings, so each hop reads one page.
Because each page holds hundreds of keys, the tree stays very shallow even for huge tables:
One layout detail matters for later sections. In a layout (MySQL InnoDB is the common example) the primary key order is the physical table order. Secondary indexes then store the primary key as their row locator, so a secondary lookup may need a second tree traversal to reach the row.
LSM vs B-tree trade-offs
An LSM design batches writes in memory and flushes sorted immutable files. Newer values may overlap older versions in other files, so reads consult indexes and filters while compaction merges files and discards obsolete versions when safe. This converts many random writes into sequential work, but compaction consumes bandwidth and can amplify physical writes. Compare read, write, and space amplification under a sustained workload, including background maintenance.
A B+ tree pays for its fast reads by updating pages in place, which means random disk writes. When the workload is mostly appends (events, metrics, messages), an tree flips the trade: it accepts writes into memory and sequential files first, then merges them in the background. The write path looks like this:
LSM Tree Write Path:
1. Write → WAL (durability) + Memtable (in-memory skip list)
2. Memtable full → flush to immutable SSTable on disk
3. SSTables accumulate → background compaction merges & deduplicatesThe memtable is the in-memory buffer, an SSTable (sorted string table) is an immutable sorted file on disk, and compaction is the background merge that removes duplicates. Reads may have to check several SSTables until compaction catches up, which is the read amplification the table below refers to.
| Aspect | B-Tree | LSM Tree |
|---|---|---|
| Write | Random I/O | Sequential I/O (10-30x faster) |
| Read | 1 index traversal | May check multiple levels |
| Space amplification | ~1x (in-place) | 1.1-2x (compaction) |
| Write amplification | ~2x | 10-30x (compaction) |
| Use case | OLTP mixed | Write-heavy / time-series |
Rule of thumb: B-trees fit mixed OLTP user data; LSM layouts fit append-heavy message, metric, and event streams.
Index families beyond the B-tree
B+ trees handle equality and range predicates, which covers most application queries. Some access patterns need a different shape, and knowing the four below tells you when to reach for one.
Hash index
A hash function maps each key to a bucket, giving O(1) exact-match lookups. It cannot support range queries, ORDER BY, or partial matching, because hashed keys have no order. Best for equality-only lookups on high-cardinality columns.
Bitmap index
One bit array per distinct value, so it suits low-cardinality columns (status, boolean). Combining filters is a CPU-friendly bitwise AND/OR. Common in analytical engines and warehouses; not suitable for write-heavy OLTP tables because every update rewrites bit arrays.
Full-text: GIN and inverted indexes
Inverted indexes map each token (a word or value) to the list of rows containing it; GIN (Generalized Inverted Index) is the PostgreSQL implementation. They power full-text search, JSON containment, and array-style operators. Build time is expensive but query time is near O(1) per token lookup.
Geospatial: R-tree, GiST and Geohash
An R-tree nests bounding rectangles so 2D spatial data can be searched hierarchically; GiST is the PostgreSQL framework that hosts it. A Geohash encodes lat/lng into string prefixes so nearby points share a prefix and a range scan finds them. Useful for "find drivers within 5km" style searches.
Shaping a B-tree index: covering, partial, expression, and column order
Back on the common B+ tree, most of the design skill is in how you shape it: which columns to include, which rows to cover, and in what order to sort the columns. The three index types come first, then the ordering rule.
Covering, partial, and expression indexes
A covering index contains the values needed by a query, potentially avoiding visits to the table’s row storage. Visibility rules can still require heap checks in MVCC engines. A partial index stores only rows matching a predicate, such as open orders, which reduces size when that subset is small. An expression index stores a computed search value, such as a normalized email. The query must use compatible expressions and predicates for the planner to benefit.
The heap is the table's own storage. A normal index lookup finds the key in the index and then reads the heap page for the rest of the row; a skips that second read, a indexes fewer rows, and an expression index stores a computed value such as LOWER(email) so the planner can use it when the query writes the same expression.
COVERING INDEX (Index-Only Scan)
CREATE INDEX idx_cover ON orders(user_id, status)
INCLUDE (total, created_at);
SELECT total, created_at FROM orders
WHERE user_id = 42 AND status = 'shipped';Index-only scan: about 0.05ms versus 3ms with a heap lookup (60x faster). At 1B rows that eliminates billions of random I/O operations.
PARTIAL INDEX (Conditional)
CREATE INDEX idx_active ON users(email)
WHERE deleted_at IS NULL;When 95% of queries target non-deleted users, this index is 20x smaller than a full index and only helps queries that repeat the same WHERE clause.
EXPRESSION INDEX
CREATE INDEX idx_lower_email ON users(LOWER(email));The planner uses this index only when the query matches the expression exactly, so a query on plain email will not benefit.
Composite column order
For recent rides, an index on rider_id, created_at, and id groups one rider’s entries together and orders them by time with a stable tie-breaker. Equality on rider_id narrows the region; the timestamp ordering lets the database retrieve the newest 20 without sorting that rider’s full history. A query for all rides in a time range has a different leading constraint and may need a different index or a scan. Engine-specific skip scans can sometimes use later columns, so “never usable without the first column” is too absolute.
A composite B-tree index is ordered lexicographically: the first key groups entries, the next orders within those groups, and so on. Leading equality filters usually make the strongest range restriction, followed by an appropriate range or ordering column. Later-column filters can still help, and some engines support skip scans, but efficiency depends on cardinality and the chosen plan. Verify the actual query instead of treating the leftmost-prefix rule as a universal prohibition. See PostgreSQL multicolumn-index documentation for the precise contract.
Index on (country, city, created_at):
- ✅ WHERE country = 'US' AND city = 'NYC' AND created_at > '2024-01-01' (uses all three columns)
- ✅ WHERE country = 'US' AND city = 'NYC' (a leftmost prefix still works)
- ❌ WHERE city = 'NYC' (skips the leading column, so the sort order is useless)
- ❌ WHERE created_at > '2024-01-01' (leading column missing, so the planner scans instead)
ESR rule (Equality, then Sort, then Range):
- E: Equality predicates first (country = 'US'), because they narrow the search the most.
- S: Sort or ORDER BY columns next (ORDER BY created_at), so leaves are already in order.
- R: Range predicates last (age BETWEEN 25 AND 40), because a range ends prefix matching.
CREATE INDEX idx_optimal ON users(country, created_at, age);(distinct values divided by total rows):
- email: ≈ 1.0 (unique), so it is an excellent index candidate on its own.
- status: ≈ 0.0001 (5 values), poor alone but useful inside a composite index.
- country: ≈ 0.005 (200 values), moderate, so pair it with a more selective column.
Rule: put high-selectivity equality columns first, then match the sort and range needs of the query.
Numbers to know: index storage and the write bill
Every shape above costs disk, memory, and write throughput. This card collects the estimates so later sections can point back here instead of repeating them.
What one inserted row touches
One inserted row
Base table page
Primary key index
Email lookup index
Organization query index
Status plus created_at index
Durability log bytes
Replicated log to followers
The diagram shows a users table with four indexes: one insert becomes five structure updates, all of which also flow into the durability log and across to every replica. The table below turns that picture into numbers.
| Quantity | Example estimate | What to say |
|---|---|---|
| Base row | User row: 16B id + 80B email + 200B profile + headers ≈ 400-800B | Round up; row headers, alignment, and nullable fields are real. |
| Index entry | Key bytes + row pointer / primary key + page overhead ≈ 30-120B per row | A small secondary index can still be hundreds of MB at 10M rows. |
| Write amplification | 1 table + 4 indexes = at least 5 logical structures per insert | Each extra index taxes writes, cache, compaction / cleanup, and replication. |
| Replication multiplier | 2 GB table+indexes x 3 copies = ~6 GB before backups and log retention | Replicas copy indexes too; storage math must include them. |
One index entry calculator
Key bytes + row locator + per-entry overhead + fill-factor headroom ≈ bytes per index row. For the Recent-rides index:
- Key bytes:
user_id 16B + created_at 8B, the columns the index sorts. - Row locator: the primary key or a pointer to the heap, often 8-24B.
- Per-entry overhead: visibility bits, length, null flags, and a share of page metadata.
- Fill factor and page splits: add 10-30% headroom, since pages are never packed full.
- Example: 100M rows x 80B = 8 GB raw; with 25% overhead and 3 copies, plan roughly 30 GB.
Cache residency check
Compare active index pages with memory, not only disk. A 6 GB hot index in an 8 GB cache may make p95 reads boring; a 60 GB hot index on the same node keeps touching disk and steals memory from other tables. Disk size tells you the bill; cache residency tells you whether reads will actually be fast.
Write amplification, bloat, and when not to index
An index adds ongoing maintenance as well as initial storage. Inserts and deletes usually affect each applicable index, while updates depend on which fields change and the engine’s optimizations. MVCC can leave obsolete row versions or index entries that require cleanup. Measure generated log bytes, index growth, background maintenance, and write latency under the real update mix instead of assuming a fixed cost per index.
Write amplification
- Every INSERT updates all indexes on the table, plus the WAL and each replica.
- Five indexes mean six on-disk structures per insert: the table plus five indexes.
- Some engines skip index churn when indexed columns stay unchanged (heap-only tuple updates).
- Rule of thumb: keep under 5-7 indexes per OLTP table, or writes start to lag.
Index bloat and online rebuild
- MVCC versions and deletes leave dead index entries that still occupy pages.
- A bloated index can grow 2-5x larger than necessary, wasting cache and I/O.
- Online rebuilds refresh an index without blocking normal writes, where the engine supports them.
- Online index creation avoids the long exclusive table lock a plain CREATE INDEX may take.
When not to add an index
Given that bill, four situations usually mean the answer is not another index:
Verify and monitor: plans, usage stats, and debugging
With the design chosen and its cost estimated, the remaining question is whether the database actually uses the index. That is answered by the plan before launch and by usage statistics afterwards.
Plans and EXPLAIN
Read estimated row counts beside actual counts to find where the planner’s assumptions diverge from reality. An index is not automatically faster when a query returns most rows: many scattered table lookups may cost more than one sequential scan. EXPLAIN ANALYZE executes the statement, so a mutating statement can actually change data. Validate safely on representative data and inspect buffers, sorts, and scanned rows rather than judging only the plan’s node names.
prints the plan the planner picked; EXPLAIN ANALYZE runs the query and adds real row counts and timings. An ORM (object-relational mapper, the library that generates SQL from application code) hides the plan from you, which is exactly why you read it yourself. The two plans below are the same query on the same 50M-row table, before and after an index.
WITHOUT INDEX
Seq Scan on orders (cost=0..1250000 rows=50)
Rows Removed by Filter: 49,999,950
Execution Time: 3200.000 ms ← FULL TABLE SCANWITH INDEX on (user_id, status)
Index Scan using idx_user_status on orders
Buffers: shared hit=4
Execution Time: 0.052 ms ← 60,000x FASTERRED FLAGS IN EXPLAIN
- Seq Scan on a large table: the planner found no usable index for the filter.
- Sort Method: external merge means the sort spilled to disk; add ORDER BY columns to the index.
- Hash Join on million-row tables: index the join column so a cheaper join becomes possible.
- Nested Loop with an inner Seq Scan: the inner table is missing an index on the join key.
Monitoring usage
A plan proves an index works today; usage statistics prove it is still earning its write cost months later. Every major engine exposes a catalog view of scan counts and cache hits, which the pseudo-SQL below queries.
-- Find unused indexes with your engine's catalog stats
SELECT table_name, index_name, scan_count, size_bytes
FROM index_usage_stats
WHERE scan_count = 0
ORDER BY size_bytes DESC;
-- Monitor index cache hit ratio
SELECT index_cache_hits / NULLIF(index_cache_hits + index_cache_reads, 0)
AS index_cache_hit_ratio
FROM database_io_stats;Debugging a slow query
When a slow query lands on your desk, the same tools apply in a fixed order: ask what is slow, read the plan, then check the metrics that show whether a new index helped or hurt.
Questions worth asking
- Which exact query is slow, and what are its p50, p95, and p99 latencies?
- How many rows qualify before and after each predicate, so you know what to index?
- Does the query need a range scan, stable pagination, or only a point lookup?
- Is the table write-heavy enough that another index will hurt ingestion?
Metrics to mention
- Sequential scans on large tables, rows removed by filter, and sorts spilling to disk.
- Index scan count, index size, bloat, cache hit ratio, and dead tuples (old row versions).
- Lock waits during DDL (schema changes); use online builds where the engine supports them.
- Write latency after adding indexes, not just the read latency improvement.
How to explain this in an interview
Strong answer: "I'll inspect the plan, add the narrowest composite or partial covering index for the hot path, estimate disk and write amplification, build it online, then verify both read speed and write overhead."
Large-table playbook
At a billion rows every technique on this page has to be combined, because no single index is small enough on its own. This is the order that usually works.
Put together, the steps move the same lookup down this ladder of latencies:
Try it: explore index selectivity and scan cost
Move selectivity, row count, and index shape to see when a B-tree helps, when a covering index removes heap lookups, and when the planner should ignore the index entirely.
Sequential scan (no index)
Current plan
- Matching rows
- 100,000
- Estimated pages touched
- 3,000
- Effective selectivity
- 2.0%
- Planner lean
- Index path
When this index pays off
Narrow predicates on high-cardinality columns, especially when the index includes projected columns for hot queries.
When to skip or redesign
Low-cardinality filters, volatile columns, or one hot row receiving all writes. Consider partial indexes or precomputed aggregates.
The index narrows to about 100,000 row locators, then fetches heap pages. Low selectivity still hurts if the predicate matches many rows.
Low selectivity is not free. An index on a boolean flag still walks a large fraction of the tree when half the table matches.
Covering indexes trade space for latency. They shine on repeated read-heavy dashboards, not on wide rows that change every column on every update.
Wrap-up
What to remember
Match indexes to measured queries, prefer smaller or structures when they fit, and revisit the list when workloads shift. Every extra index helps some reads and taxes every write, so audit periodically with planner output and usage statistics rather than guessing.
Primary sources
- PostgreSQL Docs: Indexes: the reference for B-tree, partial, and expression indexes used in the examples above.
- MySQL InnoDB: Index Types: how a clustered primary key changes secondary index lookups.
- SQLite Docs: Query Planning: a readable explanation of how planners decide to use an index.
Practice and further reading
Use these exercises to connect the lesson to a design decision. State your assumptions, trace what happens to one request, and explain the rule that must remain true even during a failure. That rule is the design's correctness invariant.
Focus for this lesson: Design indexes from selectivity, ordering, covering needs, write amplification, memory, maintenance, and measured query plans.
Explain the decision
Why might a database ignore an index?
Read the reasoning
Low selectivity, stale statistics, type mismatch, function-wrapped columns, poor ordering, or a cheaper sequential scan can make it unattractive.
Apply it to a production scenario
An online index build causes replica lag and write latency. What is the rollout?
Read one approach
Throttle build work, monitor WAL/replication and lock waits, pause at thresholds, validate plans, then remove obsolete indexes separately.
Work through the numbers
A 500 GB table has a 120 GB index replicated three ways. What extra storage does that index consume?
Read the worked calculation
360 GB across replicas, before temporary build space, backups, and WAL amplification.
Reason through a failure
What must an online index rollout preserve?
Read the recovery reasoning
Queries remain correct and writes available; a failed build must be removable without leaving plans or migrations dependent on an invalid index.