B+-Tree Indexes
Imagine a table with 500 million rows. A query asks for all customers whose ID falls between two values. Scanning the table linearly would touch every block on disk. That is an unacceptable cost when the answer might be a handful of rows. A database needs a structure that can locate a record, or a contiguous range of records, after touching only a few disk blocks. This must hold regardless of how large the table grows. This is the exact problem the B+-tree was invented to solve. It remains the default index structure in nearly every relational database system in production today. The core difficulty is that disk I/O is orders of magnitude slower than memory access. So an index's quality is measured almost entirely by how many blocks it must read. It is not measured by how many comparisons it performs in memory. A balanced binary search tree gives comparisons. But each "step" down the tree is a separate disk read if the tree does not fit in memory. And disk reads is far too slow for an interactive query. The B+-tree solves this by making each node span an entire disk block. Each node holds hundreds of keys. So the height — and hence the number of I/Os — becomes for a large fanout . This is typically 3–4 levels for hundreds of millions of records. This article builds the B+-tree from first principles. It covers what invariants define it, and how search descends the tree. It covers how insertion handles overflow by splitting nodes. It covers how deletion handles underflow by merging or redistributing. It explains why every one of these operations costs disk I/Os. We connect the structure back to the distinction between Dense vs Sparse Indexes. The B+-tree's leaf level is precisely a dense index augmented with a sequential chain. Internal levels form a sparse, multi-level directory on top of it. By the end, you should be able to trace a search, insertion, and deletion by hand. You should be able to explain why the tree never needs rebalancing beyond local splits and merges. You should understand the parameter (the order) that governs the space/height tradeoff. This sets up later discussion of Index Selection — choosing which columns to index. It also sets up Multidimensional Indexes, which generalize the single-key ordering that B+-trees rely on.
- A B+-tree is a balanced, disk-block-oriented multiway search tree; all data pointers live in leaves, internal nodes only route search.
- Every node corresponds to one disk block and holds up to keys and pointers, where is the tree's order.
- Leaves are chained left-to-right, so the structure supports both point lookups and efficient ordered range scans.
- Search, insertion, and deletion all cost disk I/Os, where is the fanout (block size / entry size) and is the number of records.
- Insertion overflow is handled by splitting a node and pushing a key up; deletion underflow is handled by redistribution or merging, possibly shrinking the tree's height.
- Because internal nodes hold hundreds of keys, real-world B+-trees stay at height 3–4 even for billions of records, keeping index lookups to a handful of I/Os.
A B+-tree keeps all records at one uniform leaf depth and packs hundreds of keys per node, turning "index lookup cost" into a small constant number of disk I/Os no matter how large the table grows.
The idea
A B+-tree is a balanced, multiway search tree structure used to implement a database index. It generalizes the binary search tree in two essential ways. First, each node can have many children — an order node holds between and pointers (with the root exempted from the lower bound), so a single node routes search among up to keys rather than just 2. Second, the tree is height-balanced by construction: every leaf sits at exactly the same depth, so there is no notion of "left-heavy" or "right-heavy" subtrees to fix — balance is maintained automatically by the split and merge rules described below. The defining structural choice of the B+-tree, as opposed to a plain B-tree, is that all actual record pointers (or records) live only in the leaves. Internal (non-leaf) nodes store copies of key values purely to steer search; they carry no data pointers of their own. Consequently the leaf level is a complete, sorted, dense index over the search key — every distinct key value that exists in the file has an entry in some leaf — and the internal levels above it form a hierarchy of sparse directories that guide you to the right leaf in steps. Leaves are additionally linked together in key order via sibling pointers, forming a doubly- or singly-linked list across the bottom of the tree. This is what makes range queries ("all keys between and ") efficient: locate the leaf containing , then walk sibling pointers forward, never re-visiting internal nodes. Two structural invariants define correctness precisely. (1) Search-key ordering invariant: within every node, keys are stored in strictly increasing order, and for every internal node with keys and child pointers , every key in the subtree rooted at satisfies (with the obvious boundary conventions at and ). (2) Occupancy invariant: every node except the root has between and children (equivalently, between and keys), guaranteeing the tree never becomes pathologically sparse and its height stays logarithmic in the number of records. These two invariants together are what every insertion and deletion algorithm must preserve.
How it works
Node layout
A leaf node of order holds up to (key, pointer) pairs, where the pointer is either the record itself, a pointer to the record on disk, or (for a non-clustering index) a pointer to a bucket of record pointers when duplicates exist. The last slot in a leaf is reserved for the pointer to the next leaf in key order. An internal node of order holds up to keys and up to child pointers, alternating pointer, key, pointer, key, …, pointer. The order is chosen so that one node occupies exactly one disk block — if a block holds 4 KB and each (key, pointer) pair takes about 16 bytes, then , giving very high fanout.
Search
Searching for a key starts at the root and descends exactly one level per comparison step, using the routing invariant to pick the correct child, until a leaf is reached. ``` function SEARCH(node, K): if node is a leaf: scan node's keys for K if found: return the associated record pointer(s) else: return NOT_FOUND else: // internal node with keys K_1 < ... < K_{m}, children P_0 .. P_m find smallest i such that K < K_i // K_{m+1} = +infinity child <- P_i read child from disk return SEARCH(child, K) ``` For a range query `[low, high]`, first locate the leaf containing `low` via `SEARCH`, then follow the leaf's next-pointer chain, emitting matching entries, until a key exceeds `high` or the chain ends.
Insertion
Insertion first performs a search to locate the target leaf, inserts the new (key, pointer) entry in sorted position, and then repairs any resulting overflow by splitting — possibly cascading the split upward. ``` function INSERT(tree, K, recordPointer): leaf <- FIND_LEAF(tree.root, K) // descend as in SEARCH insert (K, recordPointer) into leaf in sorted order if leaf.numKeys <= n - 1: return // fits, done // leaf overflow: split into two leaves newLeaf <- new leaf node move the ceil(n/2) largest entries of leaf into newLeaf newLeaf.next <- leaf.next leaf.next <- newLeaf splitKey <- smallest key in newLeaf // COPIED up, stays in newLeaf too INSERT_IN_PARENT(tree, leaf, splitKey, newLeaf)
function INSERT_IN_PARENT(tree, left, key, right): if left is the root: newRoot <- new internal node with children [left, key, right] tree.root <- newRoot return parent <- PARENT_OF(left) // known from the descent, or via parent pointers insert (key, right) into parent immediately after left's pointer if parent.numPointers <= n: return // parent fits, done // internal node overflow: split, but push the middle key UP (do not copy it) newInternal <- new internal node mid <- the middle key of parent's (n) keys move all keys/pointers to the right of mid into newInternal remove mid from parent (it moves up, not duplicated) INSERT_IN_PARENT(tree, parent, mid, newInternal) ``` Two subtleties matter here. In a leaf split, the split key is copied up to the parent, because the key must still physically remain in the leaf level to keep the dense leaf index complete. In an internal-node split, the middle key is moved up, not copied, because internal nodes are pure routing structures with no obligation to retain every key at every level. If the split propagates all the way past the root, the tree gains exactly one new level — this is the only way a B+-tree grows in height, and it happens only when literally every node on the path from a leaf to the root was already full.
Deletion
Deletion is the mirror image: locate the leaf, remove the entry, and if the node falls below the minimum occupancy, either redistribute (borrow) from a sibling or merge with a sibling, propagating the fix upward if necessary. ``` function DELETE(tree, K): leaf <- FIND_LEAF(tree.root, K) remove (K, pointer) from leaf if leaf is the root or leaf.numKeys >= ceil(n/2) - 1: return // still legal, done sibling <- an adjacent sibling of leaf (via parent) parent <- PARENT_OF(leaf) if sibling has more than the minimum number of keys: // redistribute: borrow one entry from sibling move one entry from sibling into leaf (from the near end) update the separating key in parent to reflect the new boundary return else: // merge leaf and sibling into one node merge all entries of leaf and sibling into one leaf update sibling-chain (next) pointers to skip the removed leaf remove the pointer to the deleted leaf and its separating key from parent DELETE_ENTRY_FIXUP(tree, parent)
function DELETE_ENTRY_FIXUP(tree, node): if node is the root: if node has only one child left: tree.root <- that one child // tree shrinks by one level return if node.numPointers >= ceil(n/2): return // still legal, done sibling <- an adjacent sibling of node parent <- PARENT_OF(node) if sibling has more than the minimum number of pointers: // redistribute: rotate one key/pointer through parent move one child pointer from sibling into node, pulling the appropriate separating key down from parent and pushing a new separating key up into parent return else: // merge node and sibling, pulling the separating key down from parent merge node, the parent's separating key, and sibling into one node remove the pointer to node (or sibling) and the separating key from parent DELETE_ENTRY_FIXUP(tree, parent) ``` Notice the asymmetry with insertion: leaf merges do not pull a key down from the parent (leaves store their own keys and the merge simply concatenates two sorted key lists), but internal-node merges do pull the separating key down, because that key was only present in the parent, not duplicated in the children. This mirrors the copy-up-vs-move-up asymmetry seen in insertion.
Complexity & correctness
Height. Let be the minimum fanout guaranteed at every non-root level, i.e. . Since every non-root node has at least children, a tree of height (root at level 0, leaves at level ) has at least leaves below the root's children, hence at least leaves counting the root's own fanout of at least 2. If there are records distributed dense-index style across the leaves, this forces
With (order in the low hundreds, typical for 4–8 KB blocks and small keys), , and , meaning even a billion-row index needs only about 4–5 levels. This is the entire reason B+-trees dominate disk-backed indexing: each level is one disk I/O, so search cost is I/Os, not .
Search cost. `SEARCH` reads exactly one node per level, doing in-memory comparisons per node (binary search within the block, since keys are sorted) but only one disk I/O per level. Total: I/Os and CPU comparisons — I/O dominates in practice, so we simply say search is .
Insertion cost. Locating the leaf costs I/Os, identical to search. In the worst case, insertion triggers a chain of splits from the leaf up to the root, but each split is a local, constant-time operation (redistributing at most entries between two nodes and updating one parent pointer), and there are at most such splits, one per level. So insertion is I/Os in the worst case, and importantly amortized splits in practice, since a split only happens when a node was already completely full — most insertions cause zero splits.
Deletion cost. Symmetric: locating and removing the entry costs I/Os, and at most one merge-or-redistribute operation per level propagates upward, again in the worst case.
Correctness argument. The two invariants stated earlier — sorted-key routing and bounded occupancy per node — are exactly what every split and merge preserves, and this is provable by induction on the operations.
- Split preserves routing: a split partitions a node's keys into a "low" half and a "high" half at a well-defined boundary key; the parent gets exactly that boundary key inserted at the correct position, so every subsequent search still routes correctly, by construction of `INSERT_IN_PARENT`.
- Split preserves occupancy: splitting a node with keys (just over the max of ) into two nodes each holding roughly keys leaves both new nodes at or above , satisfying the lower bound, and clearly at or below , satisfying the upper bound.
- Merge/redistribution preserves occupancy symmetrically: a node can only underflow after a single deletion, so it holds at least keys before the fix; redistribution or merging with a sibling (itself holding at least keys) restores both nodes (or the single merged node) to a legal range, which can be checked by the arithmetic bound for merges not to overflow.
- Height only changes at the root, and only by one: a new root is created only when the previous root itself splits (insertion), and the root shrinks only when it is reduced to a single child (deletion). Both events are detected explicitly in the algorithms above, so height changes are the unique, controlled exception to "all other nodes stay within bounds," and they never cascade — one insertion or deletion changes height by at most one level.
Because these invariants hold after every single operation, they hold after any sequence of operations by induction, which is exactly why the tree never needs a global rebalancing pass: all rebalancing is local to the path from the modified leaf to the root, bounding the work per operation by the tree's height.
Honest tradeoffs. Space utilization is only guaranteed to be at least per node in the worst case (right after a split, nodes can be roughly half full); in practice, sequential insertion patterns and bulk-loading achieve much higher utilization, often 67–100%. Also, `INSERT_IN_PARENT` and `DELETE_ENTRY_FIXUP` require knowing each node's parent; real systems either store parent pointers, or (much more commonly) retain the root-to-leaf path from the initial descent in memory to avoid a second traversal. Finally, the B+-tree assumes an ordering on the search key — it does not help with multidimensional range queries or exact-match-only workloads on unordered keys, where a hash index or structures covered under Multidimensional Indexes may be preferable.
Worked example
Consider a B+-tree of order (so each node holds up to 3 keys / 4 pointers, and non-root nodes need at least children, i.e. at least 1 key). Start from an empty tree and insert keys in this order: `10, 20, 5, 6, 12, 30, 7, 17`.
| Step | Action | Resulting structure (leaf level shown; ` | ` = key separator) |
|---|---|---|---|
| Insert 10 | Root is a leaf, fits | `[10]` | |
| Insert 20 | fits | `[10, 20]` | |
| Insert 5 | fits, sorted insert | `[5, 10, 20]` | |
| Insert 6 | leaf now has 4 keys > max 3 → split. Split point: keep 2, move 2. New leaves: `[5,6]` and `[10,20]`. Copy-up key `10` becomes new root. | root `[10]`; leaves `[5,6] → [10,20]` | |
| Insert 12 | goes to leaf `[10,20]` (since ), fits | `[5,6] → [10,12,20]` | |
| Insert 30 | leaf `[10,12,20]` gets 30 → 4 keys → split into `[10,12]` and `[20,30]`; copy-up key `20` inserted into root, which now holds `[10, 20]` with 3 children | root `[10,20]`; leaves `[5,6] → [10,12] → [20,30]` | |
| Insert 7 | goes to leaf `[5,6]` → `[5,6,7]`, fits (exactly 3, the max) | leaves `[5,6,7] → [10,12] → [20,30]` | |
| Insert 17 | goes to leaf `[10,12]` → `[10,12,17]`, fits | leaves `[5,6,7] → [10,12,17] → [20,30]` |
Final tree: root `[10, 20]` with three children: `[5,6,7]`, `[10,12,17]`, `[20,30]`, all chained left to right. Height is 2 — one internal level, one leaf level — for 8 keys, consistent with being a loose upper bound (the actual height is much smaller thanks to fanout at every step, and would stay at height 2 for hundreds of keys given order 4's minimum fanout).
Now delete 6. Locate leaf `[5,6,7]`, remove 6 → `[5,7]`. Minimum keys required per non-root leaf is ; `[5,7]` has 2 keys, still legal. No merge needed. Tree unchanged in shape.
Now delete 7. Locate leaf `[5,7]`, remove 7 → `[5]`. Still exactly at the minimum (1 key), legal — no fixup triggered. This shows underflow handling only activates strictly below the minimum, not merely at it.
Now delete 5 as well (leaf now would have 0 keys, illegal). Sibling `[10,12,17]` has 3 keys (above minimum), so we redistribute: move the smallest entry, `10`, from the sibling into the underflowing leaf, and update the separating key in the root from `10` to `12` (the new smallest key of the sibling). Leaves become `[10] → [12,17] → [20,30]`, root becomes `[12, 20]`. The tree stays at height 2 with all invariants intact — exactly the local, no-cascade repair the algorithm guarantees.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Confusing B-tree with B+-tree | Assuming internal nodes store data pointers (true for B-trees, false for B+-trees) | Remember: only leaves hold record pointers; internal nodes are pure routing copies of keys |
| Copy-up vs move-up mixed up | Treating leaf splits and internal splits identically | Leaf split copies the boundary key up (it must stay in the dense leaf level); internal split moves the middle key up (it existed only once) |
| Forgetting the leaf sibling-chain update on split/merge | Only updating parent pointers, leaving next-pointers stale | Every leaf split/merge must also patch the `next` pointer of the affected leaves so range scans stay correct |
| Off-by-one in minimum occupancy | Using instead of , or forgetting the root is exempt | Root may have as few as 2 children (1 key); every other node needs children |
| Assuming height always grows by one per split | Believing every leaf split immediately grows the tree | Height only grows when the split cascades all the way to the root; most splits are absorbed by a parent with spare capacity |
| Not handling merge underflow cascades in deletion | Fixing the leaf but not checking whether the parent itself now underflows | `DELETE_ENTRY_FIXUP` must recurse upward exactly like `INSERT_IN_PARENT` does, level by level |
Pioneers & history
The B-tree family, of which the B+-tree is the disk-optimized dense-leaf variant, emerged from the practical need in the early 1970s to manage indexes that vastly exceeded available main memory, at a time when disk access was the dominant cost in any database operation. Rudolf Bayer and Edward McCreight, working at Boeing Scientific Research Labs, formalized the balanced multiway tree with guaranteed logarithmic height — engineered specifically around minimizing disk seeks rather than in-memory comparisons, a design philosophy that still defines every storage-engine index built since.
Origins · 1 minRudolf Bayer and how this was discovered
Bayer, together with Edward McCreight, introduced the B-tree in a 1972 technical report while working on large-scale index structures for disk-resident data at Boeing. His later work on symmetric binary B-trees also directly influenced the design of red-black trees, connecting the disk-oriented multiway tree lineage back to balanced binary structures used in memory.
Where this matters
Every mainstream relational database — PostgreSQL, MySQL/InnoDB, Oracle, SQL Server, SQLite — implements its primary and secondary indexes using B+-tree variants, because the structure directly optimizes for the dominant real-world cost: disk (or SSD) I/O. Clustered indexes in InnoDB store entire rows in the B+-tree leaves themselves, making the leaf level double as the physical row storage. Filesystems such as NTFS, HFS+, and Btrfs use B+-tree or B-tree variants to index directories and file extents at scale. Key-value stores and search engines that need sorted range scans (as opposed to pure hash lookups) — including many embedded stores and the internal structures behind LSM-tree-based systems' sorted runs — draw on the same core split/merge logic. Understanding the B+-tree is foundational before comparing it against alternative access methods discussed under Introduction to Indexing, deciding which columns merit an index as covered in Index Selection, or moving to structures that handle multiple search keys simultaneously, the subject of Multidimensional Indexes.
Ask the tutor: How does the B+-tree's split/merge logic change when the tree is used as a clustered index storing full rows in its leaves, rather than just pointers?
Why not just use a plain binary search tree for a database index?
A binary search tree gives comparisons, but each node typically holds a single key, so each level of descent is a separate disk block read when the tree does not fit in memory. For records that means roughly 30 disk I/Os per lookup, which is far too slow for interactive queries. A B+-tree instead sizes each node to exactly one disk block and packs in hundreds of keys, achieving fanout in the hundreds. This collapses the height to , typically only 3–5 levels even for billions of rows, directly minimizing the true bottleneck: the number of disk I/Os, not the number of comparisons.
What is the difference between a B-tree and a B+-tree?
In a classic B-tree, every node — internal or leaf — can store data pointers alongside its keys, and a search can terminate successfully at any internal node. In a B+-tree, only leaf nodes store data pointers; internal nodes hold copies of keys purely for routing, and every search must descend all the way to a leaf to find (or confirm the absence of) a record. This uniformity has two payoffs: internal nodes can pack in more keys since they don't need space for data pointers, increasing fanout, and the leaf level forms a fully linked dense index supporting efficient sequential range scans, which a plain B-tree does not support as cleanly.
What does the "order" of a B+-tree actually control?
The order sets the maximum number of pointers (children) an internal node can have, equivalently the maximum number of keys () any node stores. It is usually chosen so that one full node exactly fills one disk block, since block size is fixed by the storage hardware or filesystem. A larger order means a larger fanout at minimum occupancy, which directly shrinks the tree's height and therefore the number of I/Os per search. This is why real systems tune key size and block size carefully — smaller keys per entry mean more keys fit per block, mean higher fanout, mean shallower trees.
Why must every non-root node stay at least half full?
The minimum-occupancy invariant, children per non-root node, is what guarantees the height stays logarithmic rather than degenerating toward a linked list. If nodes were allowed to shrink to a single key, an adversarial sequence of insertions and deletions could produce a tree where most nodes hold only one entry, making the height approach instead of . The split rule (never let occupancy exceed the max) and the merge/redistribute rule (never let it fall below the min) work together to keep every root-to-leaf path within a provably bounded length.
How does a B+-tree support efficient range queries, not just point lookups?
Because all leaves are linked together in sorted key order via sibling ("next") pointers, a range query only needs one descent from the root to find the starting key's leaf. From there, it simply follows the chain of next-pointers, reading each subsequent leaf in order and emitting matching entries, until the upper bound is exceeded. This costs I/Os to reach the first leaf, plus additional I/Os to scan matching entries (since roughly entries fit per leaf) — dramatically cheaper than repeating a full root-to-leaf descent for every matching record.
Does a B+-tree ever need a global rebalancing pass, like some other balanced trees do?
No — this is one of its most important practical advantages. Every insertion triggers at most one split per level along a single root-to-leaf path, and every deletion triggers at most one merge or redistribution per level along that same path. Both operations preserve the ordering and occupancy invariants locally, and by induction the whole tree remains valid after any sequence of operations. This means the cost of any single insertion or deletion is bounded by the tree's height, , with no amortized or occasional expensive global rebuild required, unlike some other rebalancing schemes.
Can a B+-tree index support duplicate key values?
Yes. When many records share the same search key value (a non-unique secondary index), implementations typically either allow repeated key entries directly in leaf nodes, each pointing to a different record, or store one key entry per leaf pointing to an overflow bucket that chains together all record pointers sharing that key. Search and range-scan logic stays essentially the same; the leaf-level scan simply yields multiple record pointers for a matching key instead of one. The split and merge logic is unaffected, since it operates on (key, pointer) entries regardless of whether keys repeat.