Skip to content

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 p is a search tree in which each internal node has the form

P1, K1,Pr1, P2, K2,Pr2, , Kq1,Prq1, Pqqp

where each Pi is a tree pointer (to another node) and each Pri is a data pointer — a pointer to the record, or to the block, holding search key value Ki.

The constraints that make it a B-tree rather than a plain search tree:

#Constraint
1Within a node, K1<K2<<Kq1
2For every value X in the subtree at Pi: Ki1<X<Ki (with X<K1 at i=1 and Kq1<X at i=q)
3Each node has at most p tree pointers
4Each node except the root and the leaves has at least p/2 tree pointers; the root has at least 2 unless it is the only node
5A node with q tree pointers holds exactly q1 key values and q1 data pointers
6All 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 p for a real block

A node is a disk block, so p is whatever makes a node fit. With the running example's parameters (B=1024 bytes, key V=9 bytes, record pointer Pr=7 bytes, block pointer P=6 bytes):

(p×P)+((p1)×(Pr+V))B6p+16(p1)102422p1040p47.2

Take p=46, leaving a few bytes of each block for node overhead (entry count, node type, free-space pointer).

How much a B-tree holds

Random insertions and deletions leave B-tree nodes about 69% full on average, so an average node has 46×0.6932 tree pointers and therefore 31 entries:

LevelNodesEntries at this levelTree pointers out
0 (root)13132
1329921 024
21 02431 74432 768

A three-level B-tree holds 31+992+31744=32767 entries on average — comfortably more than the 30 000 records of the running example. A fourth level takes it past one million.

This is the multilevel index, made dynamic

The arithmetic is the same logfor from the previous note; only the maintenance changed. The B-tree buys its dynamism with the 31% of each block it deliberately leaves empty — space for growth inside the node, so an insertion usually touches one block instead of triggering a reorganization.

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 p larger, which makes the tree shallower.

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 p has the form

P1, K1, P2, K2, , Kq1, Pqqp

with Ki1<XKi for every value X in the subtree at Pi, and a leaf node of order pleaf has the form

K1,Pr1, K2,Pr2, , Kq1,Prq1, Pnextqpleaf

where Pnext points to the next leaf in key order.

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 Pnext — sequential access without touching the tree again.

Note the in the internal-node rule, where the B-tree had <: the separator Ki is the upper bound of the subtree at Pi, so the value equal to Ki is reachable through Pi, not through Pi+1.

Choosing p and pleaf

The two node types now have different capacities and must be sized separately. Same parameters as before:

internal: (p×P)+((p1)×V)B15p1033p68.8p=68leaf: (pleaf×(Pr+V))+PB16pleaf1018pleaf=63

p jumped from 46 to 68 — a 48% wider fan-out from the single change of removing Pr from internal nodes.

68 is not a coincidence

A B⁺-tree internal node holds V,P pairs and nothing else, which is exactly an entry of the primary index from note 17.1 — where bfri=1024/15=68. A B⁺-tree internal node is a block of a multilevel index; the only thing the tree adds is the ability to split and merge those blocks in place.

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:

HeightB-tree entriesB⁺-tree record pointers
21 0232 021
332 76794 987
41 048 5754 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 K among the separators and follows the corresponding tree pointer:

cost=h block accesses to reach the leaf+1 for the data block

For the running example, h=3, so 4 block accesses — the same figure the static multilevel index achieved, now holding under insertions and deletions.

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 a, then follows Pnext until it passes b: h accesses plus one per leaf block of the answer. The same query on a B-tree has no leaf chain, so it must traverse the tree in key order, re-reading internal nodes as it goes.

Insertion

Insertion always starts at a leaf, and only propagates upward when something overflows.

SituationAction
Leaf has roomInsert in key order. Done — one block written
Leaf is fullSplit: distribute the pleaf+1 values, and copy up the smallest value of the new right leaf into the parent — the value stays in the leaf
Internal node is fullSplit: distribute the p pointers, and move up the middle key into the parent — the value does not stay
The root splitsA 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 p=3 and pleaf=2 — deliberately tiny, so nearly every insertion overflows something.

InsertWhat happensTree after
8New tree; the root is a leaf[8]
5Fits[5,8]
1Leaf overflows (1,5,8) → split, copy up 5[5][1] [5,8]
7Leaf [5,8] overflows (5,7,8) → split, copy up 7[5,7][1] [5] [7,8]
3Fits in [1][5,7][1,3] [5] [7,8]
12Leaf [7,8] overflows → split, copy up 8 → root would be [5,7,8], 4 pointers > p → split the root, move up 7[7][5] [8][1,3] [5] [7] [8,12]
9Leaf [8,12] overflows (8,9,12) → split, copy up 9; parent [8] becomes [8,9], still within p[7][5] [8,9][1,3] [5] [7] [8] [9,12]
6Routed 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: pleaf/2 values for a leaf, p/2 tree pointers for an internal node.

SituationAction
Leaf stays at or above minimumRemove 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 spareRedistribute: 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 spareMerge 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 pointerDelete 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.

DeleteWhat happensTree after
5Leaf [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]
12Leaf [9,12][9]; at the minimum, and 12 was never a separator[7][6] [8,9][1,3] [6] [7] [8] [9]
9Leaf [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 = 3/2, exactly its minimum, so the cascade stops[7][6] [8][1,3] [6] [7] [8]

B-Tree vs. B⁺-Tree

B-treeB⁺-tree
Data pointersIn every nodeIn leaves only
OrderOne pSeparate p and pleaf
Fan-out (running example)4668
Search costh, sometimes lessAlways h
Range / ordered scanFull traversal, revisits internal nodesDescend once, follow the leaf chain
Key duplicationNone — each key stored onceSeparators duplicate leaf keys
DeletionRemoving a key from an internal node needs a replacement from a subtreeOnly leaves hold data; internal keys are separators, so deletion stays local
Used by real DBMSsRarelyAlmost 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 1/2 to 2/3. Before splitting a full node, the algorithm tries to push entries into a sibling; a split happens only when two adjacent nodes are full, and it turns them into three two-thirds-full nodes. Fewer, fuller nodes means a shallower tree, paid for with more work per insertion.

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 p where it matters most. This is the prefix B⁺-tree. Leaf entries, which carry real data pointers, are usually compressed only against their neighbours.

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 p — the constraint becomes "a node must be at least half full in bytes", and split points are chosen by byte offset rather than entry count.

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.

Built with ❤️ and curiosity