The Index, Derived from First Principles
Start from "how can we avoid reading every page?" and you are forced, step by step, into a sorted array of (key → location), then binary search, then — because the array outgrows memory and storage is read in pages — into pages of entries with a page of separators, and finally a tree of pages. Nobody designed the B+ tree; it is what falls out.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
The sequential scan reads 100,000 pages to find one row. We need a way to know *which* page holds
alice@example.comwithout reading the others.↓ - Naive solution
Keep a separate sorted array of
(email, page, slot)— one 16-byte entry per row. Sorted means binary search: log₂ 10M ≈ 24 comparisons, then one page read for the row.↓ - Why it breaks
The array is one entry per row, so it grows with the table: 160 MB for 10M rows, 16 GB for a billion. Once it does not fit in memory, each of the 24 probes lands on a different page — 24 random reads per lookup.
↓ - Better idea
Storage is read in pages, so lay the array out in 8 KB pages (~512 entries each) and keep a small directory: the first key of each page. Read the directory once, then exactly one leaf page.
↓ - Internal mechanism
The directory is itself a sorted array of
(key → page). When it outgrows one page, give it a directory too. Recurse until one page remains: that page is the root, and the structure is a tree of pages with fanout ~512.↓ - Trade-offs
Every level is a page read, but with fanout 512 a billion entries need only 4 levels, and the top ones stay cached. The price: every insert must keep the array sorted and the pages within bounds — that is the split/merge machinery of the next lesson.
↓ - Real database
PostgreSQL
btree, InnoDB indexes, SQLite, Oracle — all of them are this derivation carried to its end, with different choices about what the leaf entry points to.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
An index is a second, smaller table: the indexed column plus a pointer to where each row lives, kept sorted. Sorted data can be searched by halving, so a lookup is a few comparisons instead of a scan of everything.
The same idea lets the index answer ranges and ordering: neighbours in the index are neighbours in key order.
Stage 1 — sorted metadata and binary search
The scan wastes work because it cannot rule anything out before looking at it. Sorting rules things out: if the entries are in key order, one comparison against the middle entry discards half of them. So keep a separate array of (email, page, slot), sorted by email, and search it by halving. For 16 entries that is 4 comparisons; for 10 million, 24.
The array is *metadata about the table*, not the table. The rows stay where they are in the heap file; the index only knows where each one is. That separation is what allows many indexes on one table and is why the leaf entry of every real index ends in a row locator.
entry key page slot
[0] ada@example.com 1207 3
[1] bea@example.com 88 0
[2] cai@example.com 4410 6
...
[7] hal@example.com 41206 37
[8] ivy@example.com 9012 1
...
[15] pia@example.com 17 5
lookup 'kim@…': probe [7] → 'hal' < 'kim' → right half
probe [11] → 'lou' > 'kim' → left
probe [9] → 'jon' < 'kim' → right
probe [10] → 'kim' == target → (page 3301, slot 12)
4 comparisons, then 1 heap page readStage 2 — the array outgrows memory
One entry per row, 16 bytes each: 10 million rows is 160 MB, a billion is 16 GB. As long as it fits in RAM, binary search is 24 comparisons and no I/O. Once it does not, the array lives in the file, and binary search becomes the worst possible access pattern for a disk: the first probe is at the middle of the file, the second a quarter away, the third an eighth — every probe a different page, none of them predictable by read-ahead.
24 random page reads at ~100 µs is 2.4 ms per lookup on an SSD, and 240 ms on a spinning disk. That is faster than the scan, but it is 24 reads to make 24 comparisons: one page fetched per useful comparison. The page holds 512 entries and we used one of them.
Stage 3 — cut the array into pages, add separators
The waste is obvious once stated: a page delivers 512 entries at once, so binary search *within* a page is free after the fetch; the expensive part is choosing which page. So arrange the sorted array in page-sized chunks and keep a directory with the first key of each chunk. Reading the directory tells you the one leaf page that can hold the key; reading that page finishes the search.
Two page reads, both chosen deliberately, instead of 24 scattered ones. The directory for 10 million entries is 20,000 keys — which is 40 pages, and already too big to read at once. The next stage handles that.
PAGE 5 (separators) ada → PAGE 1 eli → PAGE 2 ivy → PAGE 3 ned → PAGE 4
PAGE 1 ada→(1207,3) bea→(88,0) cai→(4410,6) dev→(2,2)
PAGE 2 eli→(77,4) fay→(901,1) gus→(3,3) hal→(41206,37)
PAGE 3 ivy→(9012,1) jon→(15,0) kim→(3301,12) lou→(6,6)
PAGE 4 mia→(410,2) ned→(8,8) oli→(22,1) pia→(17,5)
lookup 'kim': read PAGE 5 → 'ivy' ≤ 'kim' < 'ned' → PAGE 3
read PAGE 3 → binary search 4 entries → (3301, 12)
2 page reads (chosen), 4 comparisonsStage 4 — recurse: a tree of pages
The separator page is a sorted array of (key → page) with the same problem the original array had, so it gets the same solution: cut it into pages and give it a separator page of its own. Repeat until a single page is left. That page is the root; every level below it is a directory; the bottom level is the original sorted entries. A lookup reads one page per level.
The fanout does the work. With ~512 entries per 8 KB page, one level of separators covers 512 leaves, two levels 262,144, three levels 134 million leaf pages — or 68 billion entries. Nothing you will ever index needs more than five levels, and the root plus the next level are a few hundred pages that never leave the buffer pool. A lookup that reads three pages typically performs one real disk read.
What the derivation explains
Each part of the B+ tree corresponds to a stage: leaves are stage 1 cut into pages, separators are stage 3, internal levels are stage 4. Two facts forced the shape — storage is read in pages, and the index is larger than memory — and neither has anything to do with Big-O. Why B+ Trees: Fanout, Not Big-O makes that argument quantitative.
It also explains what the structure cannot do: it is sorted on one key (or one composite key), so it answers equality, ranges, prefixes and ordering on that key and nothing else; see Composite Indexes and the Leftmost-Prefix Rule for the leftmost-prefix consequence. And it explains the cost: every insert lands in a specific leaf and must keep the page sorted and within capacity, which is where B+ Tree Internals: Pages, Splits, Merges picks up.
Key points
- An index is sorted metadata — (key → row locator) — kept separately from the table.
- Binary search over the sorted array is the first answer, and it fails the moment the array no longer fits in memory: log₂ n random page reads.
- Respecting the page turns 24 random reads into 2 chosen ones: entries in pages plus a separator page.
- The separator page overflows for the same reason, so recurse: the result is a tree of pages with fanout ~512 and height 3–4 for anything realistic.
- The shape was forced by page-oriented storage and index size, not by an asymptotic argument.
Derive the index
Keep a second, sorted copy of just the indexed column and where each row lives. Sorted means binary search: 16 entries, at most 4 comparisons, then one pointer follow. This is exactly the binary search from DSA — the index probe is the same algorithm.
When to use — and when not
- This derivation is the right mental model whenever you reason about any sorted index: B+ trees, SSTable indexes, or a sorted-array index inside a small embedded engine.
- Explaining to someone why an index costs a write per insert.
- This structure does not fit when the only question is equality on a key and ordering will never matter — a hash index is smaller and flatter; see Hash Index Internals.
- Write-dominated workloads where keeping pages sorted on every insert is the bottleneck; see LSM Trees: Why Some Engines Favour Writes.
Failure modes
- Thinking of the index as "a faster table" instead of a sorted map to row locations — leads to surprise at the heap fetch cost.
- Assuming binary search on disk is fine because it is O(log n); every probe is a random read.
- Forgetting that the index must be maintained on every write to the indexed column.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.