Internals · B+ TreesAtlasDB V4 · Indexindexbinary searchsorted arrayseparatorsderivation

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.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    The sequential scan reads 100,000 pages to find one row. We need a way to know *which* page holds alice@example.com without reading the others.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

A sorted copy of one column

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.

A sorted array of index entries (key → row locator), 16 bytes each
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 read

Stage 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.

The same entries in pages of 4, plus a page of separators
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 comparisons

Stage 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.

The derivation as a chain of forced moves
avoid reading everythinggrows with the tablerespect the pagedirectory overflows tooSeq scan: N pagessorted array, binary searcharray > RAM: log₂N random readsentries in pages + separator pagerecurse → tree, height log₅₁₂N
UserLLMAgentToolDataDecisionHumanGuardrail

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

How can we avoid reading every page?
An index is not a data structure someone invented; it is what you are forced into once you try to answer WHERE email = ? without a scan. Each stage fixes the previous stage's problem.
sorted metadata: (email → page, slot), kept separately from the table
ada
p4,3
bea
p4,5
cai
p17,3
dev
p37,0
eli
p40,3
fay
p4,4
gus
p23,1
hal
p19,2
ivy
p14,0
jon
p17,2
kim
p21,5
lou
p28,0
mia
p7,4
ned
p33,6
oli
p14,5
pia
p10,4
probe 1: lo=0 hi=15 mid=7 → 'hal' < 'kim' → search right half
Sorted array + binary search

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.

Comparisons per lookup
4 (log₂ 16 = 4)
Pages read per lookup
whole array in RAM
Index structure
16 × 16 B
Educational simulation — page sizes, costs and counters are modelled, not measured from a real engine.
1/4 · 1 · Sorted array + binary search

When to use — and when not

Use it when
  • 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.
Avoid it when

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.