Why B+ Trees: Fanout, Not Big-O
A balanced binary tree and a B+ tree are both O(log n). One of them is 24 random page reads for 10 million keys and the other is 3. The base of the logarithm is the fanout, the fanout is how many decisions fit in one page, and a page is the unit of I/O — that is the whole argument, and it flips completely in memory.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
We need a sorted structure on disk with logarithmic lookup. DSA offers several: AVL trees, red-black trees, skip lists, B-trees. All of them are O(log n).
↓ - Naive solution
Use the balanced binary tree from the textbook and store each node in the file. 10 million keys, height ~24, 24 node reads per lookup.
↓ - Why it breaks
Each node is 20 bytes on an 8 KB page and its children are wherever the allocator put them, so every level is a random page read: 24 × 100 µs = 2.4 ms on an SSD, 240 ms on a disk. Reading a page delivers 8 KB and uses 20 bytes of it.
↓ - Better idea
Make a node the size of a page and fill it with keys. A 8 KB page with 16-byte entries holds ~500 separators, so one read discards 499/500 of the key space instead of half.
↓ - Internal mechanism
The B+ tree: fanout equals entries per page, height equals log base fanout of n — 3 for 10 million keys, 4 for a billion. The top levels are a few hundred pages and stay cached, so a lookup is one or two real reads.
↓ - Trade-offs
Wider nodes mean more comparisons per node (a binary search of 500 keys), splits that copy half a page, and pages that average 70% full. Those costs are CPU and space; the cost being saved is I/O, which is 10⁴–10⁵× more expensive.
↓ - Real database
Every disk-oriented engine uses B+ trees (PostgreSQL, InnoDB, SQLite, Oracle). Memory-only engines do not: Redis sorted sets use a skip list, and in-memory columnar/OLTP engines use skip lists or cache-conscious trees.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
A lookup in any tree reads one node per level. On disk, reading a node means reading a page, and a page read is the slowest thing in the system. A binary tree over 10 million keys is 24 levels; a B+ tree with 200-way fanout is 3. Same complexity class, 8× the I/O.
The B+ tree is not cleverer. It is shaped like the storage it lives on.
Big-O alone does not explain why databases choose B+ trees
Every balanced search structure is O(log n) per lookup: AVL, red-black, treap, skip list, B-tree, B+ tree. If asymptotic complexity were the criterion, any of them would do and the simplest would win. Databases uniformly choose the most complicated one — pages with split and merge rules, separators duplicated in internal levels, sibling chains — and they do it because the constant hidden by Big-O is not a constant. It is the number of page reads, and a page read is between 10⁴ and 10⁵ times slower than anything the CPU does.
The lookup cost that matters is the height, in pages. Height is log_b(n), where the base b is the number of children a node has. A binary node has b = 2. A page-sized node has b = however many (key, child) pairs fit in a page. Everything else follows from that one difference.
binary search tree B+ tree fanout 2 ⌊(8192 − 24) / 16⌋ = 510 height ⌈log₂ 10⁷⌉ = 24 ⌈log₅₁₀ 10⁷⌉ = 3 node size ~20 B (uses 0.2% of its page) 8 KB (uses all of it) page reads / lookup 24 random 3 (2 of them cached) → ~1 real read SSD (100 µs/read) 2.4 ms 0.3 ms cold, 0.1 ms warm HDD (10 ms/read) 240 ms 30 ms cold, 10 ms warm 1 billion keys: height 30 height 4
Locality: a page is a batch of decisions
A binary node holds one key and answers one question: left or right. To ask the next question you follow a pointer to wherever the next node lives — with a general-purpose allocator, a different page almost every time. Reading 8 KB to consume 20 bytes is 0.2% efficiency, and the disk does not care that you only wanted the 20.
A B+ tree page holds ~500 separators, sorted. One read brings in a whole subtree's worth of decisions; the in-page binary search costs 9 comparisons at nanoseconds each. The page is read once and used fully. The same locality is why range scans are cheap: leaves are neighbours in the file, and 500 sequential entries come out of one read. A binary tree's in-order traversal touches one page per key.
The principle scales down. CPU caches move 64-byte lines; a node sized to a cache line beats a binary node in RAM for the same reason. It scales up: a 16 KB InnoDB page has twice the fanout of an 8 KB PostgreSQL page and is a level shorter for the same key count.
When balanced binary trees are the right answer
None of this argument applies when there are no pages. In memory, a cache miss costs ~100 ns and a hit ~1 ns; 24 pointer chases are at most a few microseconds. Red-black and AVL trees are simpler to write and to update — no split or merge, no half-full pages, a rebalance touches a handful of nodes — and their O(log n) is perfectly good. Language runtimes use them for ordered maps because they are the right tool for RAM.
Skip lists are the other in-memory favourite: probabilistically balanced, no rotations, trivially lock-free for readers. Redis sorted sets (ZADD, ZRANGEBYSCORE) are a skip list plus a hash table, and the original in-memory row store of MemSQL/SingleStore used lock-free skip lists. Neither would make sense on disk — a skip list's towers are exactly the scattered pointer chase the B+ tree exists to avoid — and both make complete sense once the data is guaranteed resident.
| Structure | Fanout | Height for 10⁷ | Locality | Lives in |
|---|---|---|---|---|
| AVL / red-black tree | 2 | 24 | none | RAM (ordered maps) |
| Skip list | ~2 per level | ~24 | none | RAM (Redis ZSET, in-memory engines) |
| Cache-line B+ tree | ~8 | 8 | cache line | RAM, cache-conscious engines |
| B+ tree, 8 KB pages | ~200–500 | 3 | page | disk / SSD (PostgreSQL, SQLite) |
| B+ tree, 16 KB pages | ~400–1000 | 3 | page | disk / SSD (InnoDB) |
What moves the fanout
Fanout is page size divided by entry size, and both are choices. Wider keys are the common way to lose: a 200-byte text key gives ~40 keys per 8 KB page and a tree two levels deeper than an 8-byte integer key. This is a concrete reason to index a surrogate integer key or a hash of a long string rather than the string itself, and why PostgreSQL rejects index entries above about a third of a page. Page size is the other lever, fixed per engine at build time: 8 KB in PostgreSQL, 16 KB in InnoDB by default.
Occupancy also matters: pages average ~70% full after random inserts, which lowers the effective fanout by the same factor. Bulk-loading sorted data can pack pages full (PostgreSQL fillfactor, InnoDB sorted index build) and shave a level off a large index.
The same numbers, from the planner's side
The planner does not know the theory; it has random_page_cost and the index height from the metapage. Its estimate for an index lookup is roughly height × random_page_cost plus one random read per matching row, and it compares that with pages × seq_page_cost for the scan. That is why a B+ tree of height 3 wins for one row and loses for 30% of the rows: the tree is cheap, the random heap fetches it leads to are not. Cost-Based Optimization shows the arithmetic; Should I Add an Index? shows the consequences.
Key points
- Big-O alone does not explain why databases choose B+ trees: both are O(log n) and one does 8× the I/O.
- Height = log_fanout(n). Fanout is how many (key, child) pairs fit in a page: ~2 for a binary node, hundreds for a page.
- 10 million keys: binary tree ~24 random page reads; B+ tree with fanout 200–500 needs 3–4, of which the top levels are cached.
- A page is a batch of decisions: one read, hundreds of comparisons at CPU speed; a binary node wastes 99.8% of the page it sits on.
- In memory the argument reverses — AVL, red-black trees and skip lists (Redis sorted sets) are the right tools when there are no pages.
- Wide keys and low occupancy cut fanout; page size raises it.
Binary tree vs B+ tree: fanout and height
When to use — and when not
- This reasoning applies whenever a sorted structure must live below the memory line — any disk or SSD index, any SSTable index block, any on-disk ordered map.
- Choosing key width for an index: a narrow key is a shorter tree.
- This reasoning does not apply when the structure is guaranteed resident — an in-memory ordered map, a Redis sorted set — where simpler balanced trees or skip lists win on update cost.
- Equality-only workloads, where a hash index has height 1 and no ordering to pay for.
Failure modes
- Choosing an index structure by complexity class and ignoring the page.
- Indexing a long text column directly: fanout ~40, a taller tree, and larger index than necessary.
- Assuming a lookup costs "height" disk reads when the top levels are in cache — or assuming they are cached on a cold replica.
- Applying disk intuition to an in-memory store and over-engineering a B+ tree where a skip list would do.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSABalanced BST (AVL / red-black) → B+ tree indexSame O(log n); the base of the log is what the disk cares about.
- DSASkip list → In-memory sorted set (Redis ZSET)The right answer once there are no pages.
- Operating SystemsPage cache and read-ahead → Sequential vs random page cost