How Indexes Work — B+tree Internals
Why a lookup needs an index at all
WHERE id = 42 on an unindexed table is a full table scan — read every row,
O(n). An index is a separate sorted structure that turns that into O(log n). Almost every relational
database uses a B+tree for it — here's why and how.
Why a B+tree, not a binary tree or a hash
Disks (and SSDs) read in pages (~8 KB), and the cost is the number of page reads. A binary tree wastes that — one key per node, ~30 levels for a billion rows = ~30 reads. A B+tree packs hundreds of keys into one page (high fanout), so the tree is only ~3–4 levels deep for a billion rows → ~3–4 page reads per lookup. A hash index does equality in O(1) but can't do ranges or ORDER BY — which is why B+tree is the default.
Structure & the two operations
- Internal nodes hold only routing keys + child pointers (which subtree to descend).
- Leaf nodes hold the actual keys + row pointers, and are chained as a sorted doubly-linked list.
Point lookup (=): start at the root, follow the pointer for your key's range down to the leaf — ~3–4 reads. Range scan / ORDER BY / BETWEEN: descend to the start leaf, then walk the leaf links sideways — this sideways chain is exactly why B+tree beats hash for ranges and sorted output.
Trace: looking up key 60
- Root. Routing keys are
[40, 80]. 60 falls in the40 ≤ key < 80range, so follow the middle child pointer. - Internal/leaf. The child node contains
[40, 50, 60, 70]. 60 is present, so the leaf entry gives the row pointer (or the row itself if the index is clustered). - Range continuation. A query for
WHERE key BETWEEN 60 AND 100would stop at the 60 entry, then use the leaf's right-link to scan70, 80, 90, 100sequentially without climbing back to the root.
Inserts & Deletes: The Mechanics of Splits and Merges
B+trees remain perfectly balanced because they grow from the bottom up during insertions and shrink during deletions. When a node exceeds its maximum capacity, it splits; when a node falls below minimum capacity, it merges or redistributes keys.
(A node can hold a maximum of $M-1 = 3$ keys and $M = 4$ pointers. Minimum capacity for a node is $\lfloor M/2 \rfloor = 2$ pointers.)
1. Leaf Node Split Trace
Suppose we have a leaf nodeA currently at max capacity holding keys: [10, 20, 30]. We insert key 25:
- Overflow State: The keys temporarily become
[10, 20, 25, 30](exceeding 3 keys). - Allocate Node: A new leaf node
A2is allocated. - Split Keys: The first $\lceil (M-1)/2 \rceil = 2$ keys stay in
A:[10, 20]. The remaining keys go toA2:[25, 30]. - Adjust Leaf Links:
A's next pointer is set to point toA2, andA2's next pointer points to the original successor ofA. - Promote Key: The smallest key in the new leaf (
25) is copied up into the parent node to act as a routing separator. (Notice that25remains in the leafA2because leaves must contain all data).
2. Internal Node Split Trace
Suppose the parent internal node was already full holding routing keys[15, 40, 70] with four child pointers: P0, P1, P2, P3. We attempt to push key 25 up from the leaf split:
- Overflow State: The internal node keys temporarily become
[15, 25, 40, 70](exceeding 3 keys). - Allocate Node: A new internal node
Parent2is allocated. - Split and Move Key: The first key
[15]remains inParent. The middle key25is moved (pushed) up to the grandparent node. unlike leaf splits,25is removed from this level. The remaining keys[40, 70]move toParent2. - Reassign Pointers:
Parentkeeps pointers:P0(for keys $< 15$) andP1(for $15 \le \text{key} < 25$).Parent2gets pointers:P2(for $25 \le \text{key} < 40$),P3(for $40 \le \text{key} < 70$), and the new child pointerP4(for $\text{key} \ge 70$).
3. Page Merge (Coalescence) Trace
When a key is deleted, a node might fall below minimum capacity. Suppose we delete10 from leaf [10, 20], leaving it with only [20] (underflow):
- Check Siblings: The underflow node looks at its right sibling
B. - Redistribution (Borrowing): If
Bhas keys[30, 40], it has surplus. The underflow node borrows30. The parent separator key is updated from30to40. - Merging: If
Bhas only[30](no surplus), the nodes must merge. We combine them into a single leaf:[20, 30]. The separator key in the parent is deleted. If this causes the parent to underflow, the merge propagates up the tree.
Clustered vs secondary (the hop that surprises people)
- Clustered index (InnoDB primary key): the table rows are the leaf nodes — data stored in key order. One lookup, you're done.
- Secondary index: its leaf stores the PK (or row-id), not the row. So
WHERE email=?finds the PK in the email index, then does a second lookup in the clustered index to fetch the row — a "bookmark lookup." A covering index avoids it (next page).
When-NOT to reach for a B+tree (and when an extra index hurts)
B+tree is the default for a reason, but it is not free and not always the right structure:
- Write-heavy, append-mostly ingest (logs, metrics, CDC). Every secondary B+tree index turns one row insert into multiple random leaf updates and occasional splits — write amplification. LSM-tree engines (RocksDB, Cassandra, InnoDB change-buffer helps only so much) batch writes into sequential SSTables and absorb this better. If your primary pain is ingest rate, not point-lookup latency, prefer LSM (or fewer indexes), not "one B+tree per column."
- Random primary keys (UUIDv4 as clustered PK). Inserts hit random leaves → constant page splits, fragmentation, poor cache locality. Prefer sequential/time-ordered keys (bigserial, ULIDs, UUIDv7) for the clustered index; keep a secondary unique index on the external UUID if clients need it.
- Pure equality at extreme scale with no ranges. Hash indexes (Postgres non-unique hash, some engines' hash) can win for
=only workloads. You give upBETWEEN,ORDER BY, prefix scans, and often durability/replication maturity — rare as a default choice. - Over-indexing. Each index is maintained on every INSERT/UPDATE/DELETE of indexed columns. An index that is never used in
WHERE/JOIN/ORDER BYis pure write tax + storage. Audit with unused-index stats; drop or replace with a partial/covering index that matches real predicates. - Partial indexes (Postgres) when only a slice is queried:
CREATE INDEX … ON orders(created_at) WHERE status = 'open'— smaller tree, less write cost for closed rows that never hit the index. Prefer partial over full when the predicate is stable and selective. - Covering / INCLUDE indexes when the query only needs indexed columns: avoid the bookmark hop to the heap. Do not INCLUDE wide columns you rarely select — you inflate every leaf for a rare query shape.
Interview drills
- L1: Why ~3–4 disk reads for a billion-row B+tree lookup?
Answer: High fanout (hundreds of keys per ~8KB page) ⇒ tree height logfanout(n) ≈ 3–4. - L2: Why can B+tree do
BETWEENefficiently and a hash index cannot?
Answer: Leaves are a sorted linked list; descend to start key then walk sideways. Hash scatters equal ranges across buckets. - L3: You added five secondary indexes "for performance" and writes halved. What happened?
Answer: Each write updates every secondary leaf (and may split). Write amplification; drop unused indexes or use partial/covering carefully. - L4: Clustered PK is UUIDv4; insert p99 latency climbs with table size. Why, and what would you change?
Answer: Random inserts scatter across leaves → splits + cache misses. Switch to sequential/time-ordered clustering key; secondary unique on UUID if needed. - L5: When would you argue for an LSM store over B+tree for the primary access path?
Answer: Sustained high ingest, range scans over time-ordered keys acceptable via SSTables, point reads secondary; accept compaction cost and read amplification trade-off.
Takeaways
- Index = sorted B+tree → O(log n); high fanout means ~3–4 page reads even for billions of rows.
- Leaves are a linked list → B+tree (not hash) for ranges/ORDER BY; hash only for equality.
- Every index adds write cost (updates + splits) — index deliberately; prefer partial/covering over reflexive full indexes.
- Secondary-index lookups may need a second hop to the clustered index.
- When-NOT: random clustered PKs, write-heavy ingest (consider LSM / fewer indexes), unused indexes, pure-equality-only niches for hash.
Re-authored for this guide; B+tree diagram hand-authored as SVG. Follows CMU 15-445 (Andy Pavlo) and DDIA ch. 3. See also: Indexes in Practice, How a Query Executes, Storage Engines — B-tree vs LSM-tree.
🤖 Don't fully get this? Learn it with Claude
Stuck on How Indexes Work — B+tree Internals? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **How Indexes Work — B+tree Internals** (Databases) and want to truly understand it. Explain How Indexes Work — B+tree Internals from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **How Indexes Work — B+tree Internals** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **How Indexes Work — B+tree Internals** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **How Indexes Work — B+tree Internals** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.