B-trees and B+trees
Why is a B-tree's fanout set by the page size, and where does its write bill arrive?
B-tree and B+tree
The constraint, derived
A block device moves a minimum unit (a page). So any comparison structure should have a branching factor that fills exactly one unit: a node smaller than a page wastes the transfer, and a node larger than a page costs two transfers. The fanout is:
- children per node
- keys in the tree
Data table
| Keys N | series | Depth (dependent reads) |
|---|---|---|
| 1K | binary tree (f = 2) | 9.97 |
| 1.3K | binary tree (f = 2) | 10.3 |
| 1.68K | binary tree (f = 2) | 10.7 |
| 2.18K | binary tree (f = 2) | 11.1 |
| 2.82K | binary tree (f = 2) | 11.5 |
| 3.65K | binary tree (f = 2) | 11.8 |
| 4.73K | binary tree (f = 2) | 12.2 |
| 6.13K | binary tree (f = 2) | 12.6 |
| 7.94K | binary tree (f = 2) | 13 |
| 10.3K | binary tree (f = 2) | 13.3 |
| 13.3K | binary tree (f = 2) | 13.7 |
| 17.3K | binary tree (f = 2) | 14.1 |
| 22.4K | binary tree (f = 2) | 14.5 |
| 29K | binary tree (f = 2) | 14.8 |
| 37.6K | binary tree (f = 2) | 15.2 |
| 48.7K | binary tree (f = 2) | 15.6 |
| 63.1K | binary tree (f = 2) | 15.9 |
| 81.8K | binary tree (f = 2) | 16.3 |
| 106K | binary tree (f = 2) | 16.7 |
| 137K | binary tree (f = 2) | 17.1 |
| 178K | binary tree (f = 2) | 17.4 |
| 230K | binary tree (f = 2) | 17.8 |
| 299K | binary tree (f = 2) | 18.2 |
| 387K | binary tree (f = 2) | 18.6 |
| 501K | binary tree (f = 2) | 18.9 |
| 649K | binary tree (f = 2) | 19.3 |
| 841K | binary tree (f = 2) | 19.7 |
| 1.09M | binary tree (f = 2) | 20.1 |
| 1.41M | binary tree (f = 2) | 20.4 |
| 1.83M | binary tree (f = 2) | 20.8 |
| 2.37M | binary tree (f = 2) | 21.2 |
| 3.07M | binary tree (f = 2) | 21.6 |
| 3.98M | binary tree (f = 2) | 21.9 |
| 5.16M | binary tree (f = 2) | 22.3 |
| 6.68M | binary tree (f = 2) | 22.7 |
| 8.66M | binary tree (f = 2) | 23 |
| 11.2M | binary tree (f = 2) | 23.4 |
| 14.5M | binary tree (f = 2) | 23.8 |
| 18.8M | binary tree (f = 2) | 24.2 |
| 24.4M | binary tree (f = 2) | 24.5 |
| 31.6M | binary tree (f = 2) | 24.9 |
| 41M | binary tree (f = 2) | 25.3 |
| 53.1M | binary tree (f = 2) | 25.7 |
| 68.8M | binary tree (f = 2) | 26 |
| 89.1M | binary tree (f = 2) | 26.4 |
| 115M | binary tree (f = 2) | 26.8 |
| 150M | binary tree (f = 2) | 27.2 |
| 194M | binary tree (f = 2) | 27.5 |
| 251M | binary tree (f = 2) | 27.9 |
| 325M | binary tree (f = 2) | 28.3 |
| 422M | binary tree (f = 2) | 28.7 |
| 546M | binary tree (f = 2) | 29 |
| 708M | binary tree (f = 2) | 29.4 |
| 917M | binary tree (f = 2) | 29.8 |
| 1.19B | binary tree (f = 2) | 30.1 |
| 1.54B | binary tree (f = 2) | 30.5 |
| 2B | binary tree (f = 2) | 30.9 |
| 2.59B | binary tree (f = 2) | 31.3 |
| 3.35B | binary tree (f = 2) | 31.6 |
| 4.34B | binary tree (f = 2) | 32 |
| 5.62B | binary tree (f = 2) | 32.4 |
| 7.29B | binary tree (f = 2) | 32.8 |
| 9.44B | binary tree (f = 2) | 33.1 |
| 12.2B | binary tree (f = 2) | 33.5 |
| 15.8B | binary tree (f = 2) | 33.9 |
| 20.5B | binary tree (f = 2) | 34.3 |
| 26.6B | binary tree (f = 2) | 34.6 |
| 34.5B | binary tree (f = 2) | 35 |
| 44.7B | binary tree (f = 2) | 35.4 |
| 57.9B | binary tree (f = 2) | 35.8 |
| 75B | binary tree (f = 2) | 36.1 |
| 97.2B | binary tree (f = 2) | 36.5 |
| 126B | binary tree (f = 2) | 36.9 |
| 163B | binary tree (f = 2) | 37.2 |
| 211B | binary tree (f = 2) | 37.6 |
| 274B | binary tree (f = 2) | 38 |
| 355B | binary tree (f = 2) | 38.4 |
| 460B | binary tree (f = 2) | 38.7 |
| 596B | binary tree (f = 2) | 39.1 |
| 772B | binary tree (f = 2) | 39.5 |
| 1,000B | binary tree (f = 2) | 39.9 |
| 1K | HAMT (f = 32) | 1.99 |
| 1.3K | HAMT (f = 32) | 2.07 |
| 1.68K | HAMT (f = 32) | 2.14 |
| 2.18K | HAMT (f = 32) | 2.22 |
| 2.82K | HAMT (f = 32) | 2.29 |
| 3.65K | HAMT (f = 32) | 2.37 |
| 4.73K | HAMT (f = 32) | 2.44 |
| 6.13K | HAMT (f = 32) | 2.52 |
| 7.94K | HAMT (f = 32) | 2.59 |
| 10.3K | HAMT (f = 32) | 2.67 |
| 13.3K | HAMT (f = 32) | 2.74 |
| 17.3K | HAMT (f = 32) | 2.82 |
| 22.4K | HAMT (f = 32) | 2.89 |
| 29K | HAMT (f = 32) | 2.96 |
| 37.6K | HAMT (f = 32) | 3.04 |
| 48.7K | HAMT (f = 32) | 3.11 |
| 63.1K | HAMT (f = 32) | 3.19 |
| 81.8K | HAMT (f = 32) | 3.26 |
| 106K | HAMT (f = 32) | 3.34 |
| 137K | HAMT (f = 32) | 3.41 |
| 178K | HAMT (f = 32) | 3.49 |
| 230K | HAMT (f = 32) | 3.56 |
| 299K | HAMT (f = 32) | 3.64 |
| 387K | HAMT (f = 32) | 3.71 |
| 501K | HAMT (f = 32) | 3.79 |
| 649K | HAMT (f = 32) | 3.86 |
| 841K | HAMT (f = 32) | 3.94 |
| 1.09M | HAMT (f = 32) | 4.01 |
| 1.41M | HAMT (f = 32) | 4.09 |
| 1.83M | HAMT (f = 32) | 4.16 |
| 2.37M | HAMT (f = 32) | 4.24 |
| 3.07M | HAMT (f = 32) | 4.31 |
| 3.98M | HAMT (f = 32) | 4.38 |
| 5.16M | HAMT (f = 32) | 4.46 |
| 6.68M | HAMT (f = 32) | 4.53 |
| 8.66M | HAMT (f = 32) | 4.61 |
| 11.2M | HAMT (f = 32) | 4.68 |
| 14.5M | HAMT (f = 32) | 4.76 |
| 18.8M | HAMT (f = 32) | 4.83 |
| 24.4M | HAMT (f = 32) | 4.91 |
| 31.6M | HAMT (f = 32) | 4.98 |
| 41M | HAMT (f = 32) | 5.06 |
| 53.1M | HAMT (f = 32) | 5.13 |
| 68.8M | HAMT (f = 32) | 5.21 |
| 89.1M | HAMT (f = 32) | 5.28 |
| 115M | HAMT (f = 32) | 5.36 |
| 150M | HAMT (f = 32) | 5.43 |
| 194M | HAMT (f = 32) | 5.51 |
| 251M | HAMT (f = 32) | 5.58 |
| 325M | HAMT (f = 32) | 5.66 |
| 422M | HAMT (f = 32) | 5.73 |
| 546M | HAMT (f = 32) | 5.81 |
| 708M | HAMT (f = 32) | 5.88 |
| 917M | HAMT (f = 32) | 5.95 |
| 1.19B | HAMT (f = 32) | 6.03 |
| 1.54B | HAMT (f = 32) | 6.1 |
| 2B | HAMT (f = 32) | 6.18 |
| 2.59B | HAMT (f = 32) | 6.25 |
| 3.35B | HAMT (f = 32) | 6.33 |
| 4.34B | HAMT (f = 32) | 6.4 |
| 5.62B | HAMT (f = 32) | 6.48 |
| 7.29B | HAMT (f = 32) | 6.55 |
| 9.44B | HAMT (f = 32) | 6.63 |
| 12.2B | HAMT (f = 32) | 6.7 |
| 15.8B | HAMT (f = 32) | 6.78 |
| 20.5B | HAMT (f = 32) | 6.85 |
| 26.6B | HAMT (f = 32) | 6.93 |
| 34.5B | HAMT (f = 32) | 7 |
| 44.7B | HAMT (f = 32) | 7.08 |
| 57.9B | HAMT (f = 32) | 7.15 |
| 75B | HAMT (f = 32) | 7.23 |
| 97.2B | HAMT (f = 32) | 7.3 |
| 126B | HAMT (f = 32) | 7.37 |
| 163B | HAMT (f = 32) | 7.45 |
| 211B | HAMT (f = 32) | 7.52 |
| 274B | HAMT (f = 32) | 7.6 |
| 355B | HAMT (f = 32) | 7.67 |
| 460B | HAMT (f = 32) | 7.75 |
| 596B | HAMT (f = 32) | 7.82 |
| 772B | HAMT (f = 32) | 7.9 |
| 1,000B | HAMT (f = 32) | 7.97 |
| 1K | B-tree, 16 KB page (f ≈ 680) | 1.06 |
| 1.3K | B-tree, 16 KB page (f ≈ 680) | 1.1 |
| 1.68K | B-tree, 16 KB page (f ≈ 680) | 1.14 |
| 2.18K | B-tree, 16 KB page (f ≈ 680) | 1.18 |
| 2.82K | B-tree, 16 KB page (f ≈ 680) | 1.22 |
| 3.65K | B-tree, 16 KB page (f ≈ 680) | 1.26 |
| 4.73K | B-tree, 16 KB page (f ≈ 680) | 1.3 |
| 6.13K | B-tree, 16 KB page (f ≈ 680) | 1.34 |
| 7.94K | B-tree, 16 KB page (f ≈ 680) | 1.38 |
| 10.3K | B-tree, 16 KB page (f ≈ 680) | 1.42 |
| 13.3K | B-tree, 16 KB page (f ≈ 680) | 1.46 |
| 17.3K | B-tree, 16 KB page (f ≈ 680) | 1.5 |
| 22.4K | B-tree, 16 KB page (f ≈ 680) | 1.54 |
| 29K | B-tree, 16 KB page (f ≈ 680) | 1.58 |
| 37.6K | B-tree, 16 KB page (f ≈ 680) | 1.62 |
| 48.7K | B-tree, 16 KB page (f ≈ 680) | 1.65 |
| 63.1K | B-tree, 16 KB page (f ≈ 680) | 1.69 |
| 81.8K | B-tree, 16 KB page (f ≈ 680) | 1.73 |
| 106K | B-tree, 16 KB page (f ≈ 680) | 1.77 |
| 137K | B-tree, 16 KB page (f ≈ 680) | 1.81 |
| 178K | B-tree, 16 KB page (f ≈ 680) | 1.85 |
| 230K | B-tree, 16 KB page (f ≈ 680) | 1.89 |
| 299K | B-tree, 16 KB page (f ≈ 680) | 1.93 |
| 387K | B-tree, 16 KB page (f ≈ 680) | 1.97 |
| 501K | B-tree, 16 KB page (f ≈ 680) | 2.01 |
| 649K | B-tree, 16 KB page (f ≈ 680) | 2.05 |
| 841K | B-tree, 16 KB page (f ≈ 680) | 2.09 |
| 1.09M | B-tree, 16 KB page (f ≈ 680) | 2.13 |
| 1.41M | B-tree, 16 KB page (f ≈ 680) | 2.17 |
| 1.83M | B-tree, 16 KB page (f ≈ 680) | 2.21 |
| 2.37M | B-tree, 16 KB page (f ≈ 680) | 2.25 |
| 3.07M | B-tree, 16 KB page (f ≈ 680) | 2.29 |
| 3.98M | B-tree, 16 KB page (f ≈ 680) | 2.33 |
| 5.16M | B-tree, 16 KB page (f ≈ 680) | 2.37 |
| 6.68M | B-tree, 16 KB page (f ≈ 680) | 2.41 |
| 8.66M | B-tree, 16 KB page (f ≈ 680) | 2.45 |
| 11.2M | B-tree, 16 KB page (f ≈ 680) | 2.49 |
| 14.5M | B-tree, 16 KB page (f ≈ 680) | 2.53 |
| 18.8M | B-tree, 16 KB page (f ≈ 680) | 2.57 |
| 24.4M | B-tree, 16 KB page (f ≈ 680) | 2.61 |
| 31.6M | B-tree, 16 KB page (f ≈ 680) | 2.65 |
| 41M | B-tree, 16 KB page (f ≈ 680) | 2.69 |
| 53.1M | B-tree, 16 KB page (f ≈ 680) | 2.73 |
| 68.8M | B-tree, 16 KB page (f ≈ 680) | 2.77 |
| 89.1M | B-tree, 16 KB page (f ≈ 680) | 2.81 |
| 115M | B-tree, 16 KB page (f ≈ 680) | 2.85 |
| 150M | B-tree, 16 KB page (f ≈ 680) | 2.89 |
| 194M | B-tree, 16 KB page (f ≈ 680) | 2.93 |
| 251M | B-tree, 16 KB page (f ≈ 680) | 2.97 |
| 325M | B-tree, 16 KB page (f ≈ 680) | 3.01 |
| 422M | B-tree, 16 KB page (f ≈ 680) | 3.05 |
| 546M | B-tree, 16 KB page (f ≈ 680) | 3.08 |
| 708M | B-tree, 16 KB page (f ≈ 680) | 3.12 |
| 917M | B-tree, 16 KB page (f ≈ 680) | 3.16 |
| 1.19B | B-tree, 16 KB page (f ≈ 680) | 3.2 |
| 1.54B | B-tree, 16 KB page (f ≈ 680) | 3.24 |
| 2B | B-tree, 16 KB page (f ≈ 680) | 3.28 |
| 2.59B | B-tree, 16 KB page (f ≈ 680) | 3.32 |
| 3.35B | B-tree, 16 KB page (f ≈ 680) | 3.36 |
| 4.34B | B-tree, 16 KB page (f ≈ 680) | 3.4 |
| 5.62B | B-tree, 16 KB page (f ≈ 680) | 3.44 |
| 7.29B | B-tree, 16 KB page (f ≈ 680) | 3.48 |
| 9.44B | B-tree, 16 KB page (f ≈ 680) | 3.52 |
| 12.2B | B-tree, 16 KB page (f ≈ 680) | 3.56 |
| 15.8B | B-tree, 16 KB page (f ≈ 680) | 3.6 |
| 20.5B | B-tree, 16 KB page (f ≈ 680) | 3.64 |
| 26.6B | B-tree, 16 KB page (f ≈ 680) | 3.68 |
| 34.5B | B-tree, 16 KB page (f ≈ 680) | 3.72 |
| 44.7B | B-tree, 16 KB page (f ≈ 680) | 3.76 |
| 57.9B | B-tree, 16 KB page (f ≈ 680) | 3.8 |
| 75B | B-tree, 16 KB page (f ≈ 680) | 3.84 |
| 97.2B | B-tree, 16 KB page (f ≈ 680) | 3.88 |
| 126B | B-tree, 16 KB page (f ≈ 680) | 3.92 |
| 163B | B-tree, 16 KB page (f ≈ 680) | 3.96 |
| 211B | B-tree, 16 KB page (f ≈ 680) | 4 |
| 274B | B-tree, 16 KB page (f ≈ 680) | 4.04 |
| 355B | B-tree, 16 KB page (f ≈ 680) | 4.08 |
| 460B | B-tree, 16 KB page (f ≈ 680) | 4.12 |
| 596B | B-tree, 16 KB page (f ≈ 680) | 4.16 |
| 772B | B-tree, 16 KB page (f ≈ 680) | 4.2 |
| 1,000B | B-tree, 16 KB page (f ≈ 680) | 4.24 |
That is the whole argument for the B-tree. It is not an asymptotic argument. It is that log base 680 of anything you will ever store is a small number.
Move the medium and the fanout moves with it. In memory the transfer unit is a 64-byte cache line, so the best in-memory B-tree has a fanout in the single digits and looks nothing like the disk version. This is why cache-conscious B+trees and ART beat red-black trees in memory for exactly the reason B-trees beat them on disk. The structure is invariant. The constant is set by the layer below. (Batch-size law again.)
B+ versus B: a range-query argument
In a plain B-tree every node holds keys and values. In a B+tree values are stored only in the leaves (the bottom level), and the leaves are linked into a sorted list. Two gains: internal nodes have higher fanout (no payload competes for page space), and a range scan becomes a sequential walk over linked pages instead of repeated descents from the root. Sequential I/O is one to two orders of magnitude cheaper than random I/O, and range predicates are the majority of real query load, so there is no remaining case for a plain B-tree in storage.
The write path is where the bill arrives
In-place update means each row update is a read-modify-write of a full page. The write amplification is page size ÷ row size.
Key order relocates cost
Key order is a lever that moves cost. It is not a detail.
- Random keys (for example UUIDv4 as the primary key) land everywhere in the tree. Pages split all over the place and in steady state are only about 69% full (the classic ln 2 result; ln 2 = 0.693). To store the same data you need 1/0.69 ≈ 1.45× the pages: a 45% space overhead. Your working set (the pages you must keep in memory) inflates by the same factor.
- Sequential keys (auto-increment numbers, UUIDv7, ULID: identifiers that begin with a timestamp) append at the right edge of the tree. Pages fill completely and fragmentation disappears. In exchange, you create one hot page and its latch that every concurrent inserter must pass through.
You did not fix the problem. You converted a space cost into a contention cost. That is Conservation of cost.
Concurrency is why B-trees are hard, not why they are slow
Latch coupling (also called crabbing) means: take the child's latch before you release the parent's. A split that propagates upward then serializes against the root. B-link trees add a pointer from each node to its right sibling, so a reader that arrives in the middle of a split can follow the pointer sideways instead of holding the parent. This removes the need for a top-down exclusive latch in the common case. The root latch is a coherency term (the cost of keeping many cores consistent): a fixed serial fraction that no number of extra cores dissolves.
Copy-on-write B-trees
Copy-on-write B-trees (LMDB, ZFS, Btrfs) write a new path from leaf to root on every update instead of changing pages in place. This gives crash safety without a WAL and MVCC snapshots (consistent views of old data) for free: a reader simply holds an old root pointer. It costs space amplification and needs a collector to free pages that are no longer reachable. That is the Immutability law, now inside a structure rather than a storage device.
The honest boundary condition
B-trees are best when the internal nodes fit in memory and updates are not the dominant operation. They lose badly when (update rate × page/row ratio) exceeds the device's write bandwidth. That is precisely the regime the LSM-tree was invented for.