Introduction
The previous note ended with a search tree that was correct but unusable: nothing stopped it from becoming unbalanced, nothing stopped its nodes from sitting nearly empty, and deletion could wreck it. Two extra constraints fix all three problems at once:
- every node must be at least half full;
- every leaf must sit at the same level.
A search tree with those constraints is a B-tree. It is a dynamic multilevel index: it grows and shrinks one node at a time, stays balanced without reorganization, and never needs the overflow chains that ruined ISAM.
B-Trees
Definition
A B-tree of order
where each
The constraints that make it a B-tree rather than a plain search tree:
| # | Constraint |
|---|---|
| 1 | Within a node, |
| 2 | For every value |
| 3 | Each node has at most |
| 4 | Each node except the root and the leaves has at least |
| 5 | A node with |
| 6 | All leaves are at the same level; leaves have the same structure but all their tree pointers are null |
Constraint 4 is the minimum occupancy rule and constraint 6 is the balance rule — together they are the entire difference from the previous note's search tree.
The crucial structural fact: a data pointer sits next to every key, at every level. A search for a value stored in the root finds it — and its record — after a single block access.
Choosing for a real block
A node is a disk block, so
Take
How much a B-tree holds
Random insertions and deletions leave B-tree nodes about 69% full on average, so an average node has
| Level | Nodes | Entries at this level | Tree pointers out |
|---|---|---|---|
| 0 (root) | 1 | 31 | 32 |
| 1 | 32 | 992 | 1 024 |
| 2 | 1 024 | 31 744 | 32 768 |
A three-level B-tree holds
This is the multilevel index, made dynamic
The arithmetic is the same
B⁺-Trees
The B-tree wastes its most valuable resource. Data pointers occupy 7 of every 16 bytes in an internal node, yet they are useful only for the handful of searches that stop early at that node. Moving them out makes internal nodes narrower, which makes
That is the B⁺-tree: data pointers live only in the leaves, and internal nodes hold nothing but keys and tree pointers.
Definition
In a B⁺-tree, an internal node of order
with
where
Three consequences follow, and every one of them matters in practice:
- Every search costs the same — it always ends at a leaf, because that is where the data pointers are. No early termination, but no variance either.
- Every key appears in a leaf. A key in an internal node is only a routing separator; it may be a duplicate of a leaf key, and it may even name a value that has since been deleted.
- The leaves form a linked list. A range query descends once, then walks
— sequential access without touching the tree again.
Note the
Choosing and
The two node types now have different capacities and must be sized separately. Same parameters as before:
68 is not a coincidence
A B⁺-tree internal node holds
At 69% fill, an internal node averages 47 tree pointers and a leaf averages 43 entries. Capacity by height, against the B-tree of the same block size:
| Height | B-tree entries | B⁺-tree record pointers |
|---|---|---|
| 2 | 1 023 | 2 021 |
| 3 | 32 767 | 94 987 |
| 4 | 1 048 575 | 4 464 389 |
The running example's 30 000 records need three levels either way — but the B⁺-tree can triple in size before it needs a fourth.
Searching
Search descends from the root, and at each node finds the position of
For the running example,
A range query is where the two structures separate. WHERE Ssn BETWEEN a AND b on a B⁺-tree descends once to the leaf holding
Insertion
Insertion always starts at a leaf, and only propagates upward when something overflows.
| Situation | Action |
|---|---|
| Leaf has room | Insert in key order. Done — one block written |
| Leaf is full | Split: distribute the |
| Internal node is full | Split: distribute the |
| The root splits | A new root is created with a single key. This is the only way a B⁺-tree gains height, which is why it stays balanced |
The copy-up/move-up asymmetry is the whole reason B⁺-trees work: a leaf may not lose a value (it holds the only data pointer to it), while an internal node's key is only a separator and can move freely.
Trace: inserting 8, 5, 1, 7, 3, 12, 9, 6
With
| Insert | What happens | Tree after |
|---|---|---|
| 8 | New tree; the root is a leaf | [8] |
| 5 | Fits | [5,8] |
| 1 | Leaf overflows (1,5,8) → split, copy up 5 | [5] → [1] [5,8] |
| 7 | Leaf [5,8] overflows (5,7,8) → split, copy up 7 | [5,7] → [1] [5] [7,8] |
| 3 | Fits in [1] | [5,7] → [1,3] [5] [7,8] |
| 12 | Leaf [7,8] overflows → split, copy up 8 → root would be [5,7,8], 4 pointers > | [7] → [5] [8] → [1,3] [5] [7] [8,12] |
| 9 | Leaf [8,12] overflows (8,9,12) → split, copy up 9; parent [8] becomes [8,9], still within | [7] → [5] [8,9] → [1,3] [5] [7] [8] [9,12] |
| 6 | Routed left of 7, right of 5; fits in [5] | [7] → [5] [8,9] → [1,3] [5,6] [7] [8] [9,12] |
Note step 12: one leaf split cascaded into a root split and the tree grew from two levels to three — the only moment its height changed, and every leaf moved down together.
Deletion
Deletion also starts at a leaf, and only propagates upward when a node falls below its minimum:
| Situation | Action |
|---|---|
| Leaf stays at or above minimum | Remove the entry. If the value also appears as a separator above, replace it there with the next value (it is only a separator, so leaving it is also correct) |
| Leaf underflows, a sibling has a spare | Redistribute: move one entry across from the sibling and update the separator in the parent. Cheaper than merging, and it touches three blocks |
| Leaf underflows, no sibling can spare | Merge the leaf with a sibling, delete the now-unused separator from the parent — which may make the parent underflow, and so on upward |
| The root is left with one pointer | Delete it and make its only child the new root. This is the only way a B⁺-tree loses height |
Redistribute before you merge
Merging is the expensive path: it deletes a separator from the parent and can cascade all the way to the root. Always check whether an adjacent sibling has a spare entry first — most underflows are absorbed by a single redistribution and never touch the level above.
Trace: deleting 5, 12, 9
Continuing from the tree built above.
| Delete | What happens | Tree after |
|---|---|---|
| 5 | Leaf [5,6] → [6], still at the minimum of 1. But 5 is a separator in the level above, so it is replaced by 6 | [7] → [6] [8,9] → [1,3] [6] [7] [8] [9,12] |
| 12 | Leaf [9,12] → [9]; at the minimum, and 12 was never a separator | [7] → [6] [8,9] → [1,3] [6] [7] [8] [9] |
| 9 | Leaf [9] empties → underflow. The left sibling [8] holds exactly its minimum, so it cannot lend → merge, and remove separator 9 from the parent. The parent drops to 2 tree pointers = | [7] → [6] [8] → [1,3] [6] [7] [8] |
B-Tree vs. B⁺-Tree
| B-tree | B⁺-tree | |
|---|---|---|
| Data pointers | In every node | In leaves only |
| Order | One | Separate |
| Fan-out (running example) | 46 | 68 |
| Search cost | Always | |
| Range / ordered scan | Full traversal, revisits internal nodes | Descend once, follow the leaf chain |
| Key duplication | None — each key stored once | Separators duplicate leaf keys |
| Deletion | Removing a key from an internal node needs a replacement from a subtree | Only leaves hold data; internal keys are separators, so deletion stays local |
| Used by real DBMSs | Rarely | Almost universally |
The B⁺-tree wins on the two things that decide index performance: a wider fan-out (fewer levels) and a leaf chain (cheap range scans). When a DBMS manual says "index", it means a B⁺-tree.
Variations
Real implementations depart from the textbook structure in a few consistent ways.
B*-trees. Raise the minimum occupancy from
Key compression. Separators in internal nodes only need to distinguish subtrees, not reproduce keys exactly. With character keys, storing the shortest distinguishing prefix — Br instead of Brown — packs far more separators into a block, raising
Bulk loading. Building an index by inserting records one at a time costs a full root-to-leaf descent per record and leaves nodes ~69% full. Bulk loading instead sorts the key values, fills leaf blocks sequentially to a chosen fill factor, then builds each level above from the level below. It is dramatically faster, and the fill factor is a tuning knob: pack tight (95%) for a read-only table, leave slack (60%) for one that will grow.
Variable-length keys. With VARCHAR keys there is no single
Duplicate keys. On a nonkey field a value maps to many records. Implementations either repeat the key once per record pointer, or store one key with a list of record pointers (the indirection of note 17.1), or append a unique tiebreaker such as the row id to make every key distinct.
Concurrency. Because every insertion and deletion begins at the root, a naive lock on the root would serialize the whole index. Real systems use latch-coupling: latch a child, release the parent as soon as the child is known not to split — which is why "will this node split?" is decided on the way down, not on the way back up.