Internals · B+ Treesfanouttree heightlocalityrandom I/Obinary tree

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.

▶ InteractiveInterview question
Progress

Why this exists

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

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

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

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

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

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

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

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

Height is what you pay for

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.

10 million keys, 8 KB pages, 16-byte entries
                      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.

Sorted structures by where the data lives
StructureFanoutHeight for 10⁷LocalityLives in
AVL / red-black tree224noneRAM (ordered maps)
Skip list~2 per level~24noneRAM (Redis ZSET, in-memory engines)
Cache-line B+ tree~88cache lineRAM, cache-conscious engines
B+ tree, 8 KB pages~200–5003pagedisk / SSD (PostgreSQL, SQLite)
B+ tree, 16 KB pages~400–10003pagedisk / 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

PostgreSQL implementation

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

Why not a balanced binary tree?
Both are O(log n). Only one of them survives contact with a disk: the base of the logarithm is the number of children per page, and each level is a random page read.
fanout = ⌊(8.0 KB − 24 B header) / (8 B key + 8 B child pointer)⌋ = 510 children per page · binary node = 2 children
Tree height (levels)
binary search tree
24
B+ tree (fanout 510)
3
Random page reads per lookup (cold cache)
binary search tree
24
B+ tree (fanout 510)
3
Latency on SSD (100 µs per random read)
binary search tree
2.4 ms
B+ tree (fanout 510)
300 µs
Latency on HDD (10 ms per random read)
binary search tree
240 ms
B+ tree (fanout 510)
30 ms
log₂ n
24
log510 n
3
Speed-up
8.0×
Levels cached (warm)
2 of 3
Real reads, warm
≈ 1
Educational simulation — a binary node is assumed to live on its own page (the realistic worst case once the tree exceeds memory); fanout ignores per-key overhead and free space.
Big-O alone does not explain why databases choose B+ trees. Both lookups are O(log n). At 10.0M keys the binary tree is 24 levels deep and the B+ tree 3: the ratio is log₂ 510 ≈ 9.0, and every level is a random read that costs 100 µs on an SSD or 10 ms on a disk.
Locality
One 8 KB page holds 510 separators — a whole subtree's worth of decisions arrives in one read. A binary node holds one key, and its two children are wherever the allocator put them.
In RAM the story flips
AVL and red-black trees are fine when a pointer chase costs ~100 ns. Redis sorted sets and in-memory engines use skip lists for the same reason: no pages, so fanout buys nothing and simplicity wins.
Range scans
B+ leaves are sorted and chained: after the descent, a range is sequential reads of neighbouring pages. An in-order walk of a binary tree touches a scattered page per node.

When to use — and when not

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