B+ trees
B+ trees: internal and leaf nodes, chained leaves, range scans, index pages and how they differ from B-trees.
Learning goals
- Describe what internal nodes and leaves store in a B+ tree
- Follow a leaf split and compare it with an internal split
- Run a range scan with one descent and a walk along the leaf chain
- Estimate fan-out and index depth from the page size
- State the trade-offs against a plain B-tree
Prerequisites
Most database indexes are B+ trees. They put every record in the leaves and link the leaves in order, which makes range queries and full scans as cheap as reading a sequential file.
Internal vs leaf nodes
In a **B+ tree** the internal nodes hold only **separator keys** that guide the search, and **all the data** lives in the leaves. A separator that equals a key may appear in an internal node and again in a leaf: it is a copy. Because internal nodes carry no data, they can hold more separators per page, which makes the tree shallower.
Run the “Leaf split, separator copied up” preset: inserting 10, 20, 30, 40 with at most 3 keys per node overflows the leaf, which splits into [10 20] and [30 40]; the separator 30 is **copied** up to the new root and is still in the right leaf. When an **internal** node overflows, its middle separator **moves** up instead, because internal keys carry no data to preserve.
1static final int CAP = 3; // maximum keys per node2static class Node {3boolean leaf = true;4List<Integer> keys = new ArrayList<>();5List<Node> kids = new ArrayList<>(); // internal nodes only6Node next; // leaves only: the next leaf in key order7}8static Node root = new Node();9static Object[] insert(Node n, int k) { // null, or {separator, new right sibling} after a split10if (n.leaf) {11int i = 0;12while (i < n.keys.size() && n.keys.get(i) < k) i++;13if (i < n.keys.size() && n.keys.get(i) == k) return null;14n.keys.add(i, k);15if (n.keys.size() <= CAP) return null;16int mid = (n.keys.size() + 1) / 2;17Node right = new Node();18right.keys.addAll(n.keys.subList(mid, n.keys.size()));19n.keys.subList(mid, n.keys.size()).clear();20right.next = n.next; n.next = right; // splice the new leaf into the chain21return new Object[]{right.keys.get(0), right}; // the separator is COPIED up22}23int i = 0;24while (i < n.keys.size() && k >= n.keys.get(i)) i++; // an equal key lives in the right subtree25Object[] up = insert(n.kids.get(i), k);26if (up == null) return null;27n.keys.add(i, (Integer) up[0]);28n.kids.add(i + 1, (Node) up[1]);29if (n.keys.size() <= CAP) return null;30int mid = n.keys.size() / 2;31int sep = n.keys.get(mid); // an internal separator MOVES up32Node right = new Node(); right.leaf = false;33right.keys.addAll(n.keys.subList(mid + 1, n.keys.size()));34right.kids.addAll(n.kids.subList(mid + 1, n.kids.size()));35n.keys.subList(mid, n.keys.size()).clear();36n.kids.subList(mid + 1, n.kids.size()).clear();37return new Object[]{sep, right};38}39static void add(int k) {40Object[] up = insert(root, k);41if (up != null) { // the root split: the tree grows in height42Node r = new Node(); r.leaf = false;43r.keys.add((Integer) up[0]); r.kids.add(root); r.kids.add((Node) up[1]);44root = r;45}46}
B+ tree: keys in leaves, copied separators, chained leaves and range scans
Interactive · edit the inputB+ tree (data only in the leaves) · 1 node
leaf
leaf chain (left to right)
A B+ tree keeps every key in the leaves, which are chained left to right; internal nodes hold only separators. Nodes hold at most 3 keys here.
1static final int CAP = 3; // maximum keys per node2static class Node {3boolean leaf = true;4List<Integer> keys = new ArrayList<>();5List<Node> kids = new ArrayList<>(); // internal nodes only6Node next; // leaves only: the next leaf in key order7}8static Node root = new Node();9static Object[] insert(Node n, int k) { // null, or {separator, new right sibling} after a split10if (n.leaf) {11int i = 0;12while (i < n.keys.size() && n.keys.get(i) < k) i++;13if (i < n.keys.size() && n.keys.get(i) == k) return null;14n.keys.add(i, k);15if (n.keys.size() <= CAP) return null;16int mid = (n.keys.size() + 1) / 2;17Node right = new Node();18right.keys.addAll(n.keys.subList(mid, n.keys.size()));19n.keys.subList(mid, n.keys.size()).clear();20right.next = n.next; n.next = right; // splice the new leaf into the chain21return new Object[]{right.keys.get(0), right}; // the separator is COPIED up22}23int i = 0;24while (i < n.keys.size() && k >= n.keys.get(i)) i++; // an equal key lives in the right subtree25Object[] up = insert(n.kids.get(i), k);26if (up == null) return null;27n.keys.add(i, (Integer) up[0]);28n.kids.add(i + 1, (Node) up[1]);29if (n.keys.size() <= CAP) return null;30int mid = n.keys.size() / 2;31int sep = n.keys.get(mid); // an internal separator MOVES up32Node right = new Node(); right.leaf = false;33right.keys.addAll(n.keys.subList(mid + 1, n.keys.size()));34right.kids.addAll(n.kids.subList(mid + 1, n.kids.size()));35n.keys.subList(mid, n.keys.size()).clear();36n.kids.subList(mid + 1, n.kids.size()).clear();37return new Object[]{sep, right};38}39static void add(int k) {40Object[] up = insert(root, k);41if (up != null) { // the root split: the tree grows in height42Node r = new Node(); r.leaf = false;43r.keys.add((Integer) up[0]); r.kids.add(root); r.kids.add((Node) up[1]);44root = r;45}46}47static List<Integer> range(int lo, int hi) {48Node n = root;49while (!n.leaf) { // descend to the leaf that would hold lo50int i = 0;51while (i < n.keys.size() && lo >= n.keys.get(i)) i++;52n = n.kids.get(i);53}54List<Integer> out = new ArrayList<>();55for (; n != null; n = n.next) { // then walk the chained leaves56for (int key : n.keys) {57if (key > hi) return out;58if (key >= lo) out.add(key);59}60}61return out;62}
Live state
max keys
3
Execution trace (dry run table)Show 15 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | max keys=3 | A B+ tree keeps every key in the leaves, which are chained left to right; internal nodes hold only separators. Nodes hold at most 3 keys here. |
| 2 | L14 | max keys=3 inserted=10 | 10 goes into the leaf [10] (1 of at most 3 keys). |
| 3 | L14 | max keys=3 inserted=20 | 20 goes into the leaf [10 20] (2 of at most 3 keys). |
| 4 | L14 | max keys=3 inserted=5 | 5 goes into the leaf [5 10 20] (3 of at most 3 keys). |
| 5 | L14 | max keys=3 inserted=6 | 6 goes into the leaf [5 6 10 20] (4 of at most 3 keys). |
| 6 | L21 | max keys=3 separator=10 | The leaf overflowed: split it into [5 6] and [10 20]. The separator 10 is COPIED up; it also stays in the right leaf, because leaves hold all the data. |
| 7 | L41 | max keys=3 separator=10 | The root split, so a new root with the single separator 10 is created: the tree is one level taller. |
| 8 | L14 | max keys=3 inserted=12 | 12 goes into the leaf [10 12 20] (3 of at most 3 keys). |
| 9 | L14 | max keys=3 inserted=30 | 30 goes into the leaf [10 12 20 30] (4 of at most 3 keys). |
| 10 | L21 | max keys=3 separator=20 | The leaf overflowed: split it into [10 12] and [20 30]. The separator 20 is COPIED up; it also stays in the right leaf, because leaves hold all the data. |
| 11 | L28 | max keys=3 separator=20 | The parent takes the new separator 20: 2 of at most 3 keys. |
| 12 | L14 | max keys=3 inserted=7 | 7 goes into the leaf [5 6 7] (3 of at most 3 keys). |
| 13 | L14 | max keys=3 inserted=17 | 17 goes into the leaf [10 12 17] (3 of at most 3 keys). |
| 14 | L14 | max keys=3 inserted=25 | 25 goes into the leaf [20 25 30] (3 of at most 3 keys). |
| 15 | L1 | keys=9 height=1 splits=2 | 9 keys in 3 leaves, tree height 1, 2 splits. |
Leaf chaining
The leaves are linked into a **chain** in key order (a singly or doubly linked list). The animation draws the chain as a row of leaves under the tree. When a leaf splits, the new leaf is spliced in right after the old one, so the chain stays sorted without touching the internal nodes.
The chain turns the leaf level into a sorted file: you can read all keys in order, from the smallest leaf to the largest, without ever visiting an internal node again. In the “Sorted input fills the leaves” preset the 10 sorted keys end in 5 chained leaves, in a tree of height 2 after 5 splits.
B+ tree: keys in leaves, copied separators, chained leaves and range scans
Interactive · edit the inputB+ tree (data only in the leaves) · 1 node
leaf
leaf chain (left to right)
A B+ tree keeps every key in the leaves, which are chained left to right; internal nodes hold only separators. Nodes hold at most 3 keys here.
1static final int CAP = 3; // maximum keys per node2static class Node {3boolean leaf = true;4List<Integer> keys = new ArrayList<>();5List<Node> kids = new ArrayList<>(); // internal nodes only6Node next; // leaves only: the next leaf in key order7}8static Node root = new Node();9static Object[] insert(Node n, int k) { // null, or {separator, new right sibling} after a split10if (n.leaf) {11int i = 0;12while (i < n.keys.size() && n.keys.get(i) < k) i++;13if (i < n.keys.size() && n.keys.get(i) == k) return null;14n.keys.add(i, k);15if (n.keys.size() <= CAP) return null;16int mid = (n.keys.size() + 1) / 2;17Node right = new Node();18right.keys.addAll(n.keys.subList(mid, n.keys.size()));19n.keys.subList(mid, n.keys.size()).clear();20right.next = n.next; n.next = right; // splice the new leaf into the chain21return new Object[]{right.keys.get(0), right}; // the separator is COPIED up22}23int i = 0;24while (i < n.keys.size() && k >= n.keys.get(i)) i++; // an equal key lives in the right subtree25Object[] up = insert(n.kids.get(i), k);26if (up == null) return null;27n.keys.add(i, (Integer) up[0]);28n.kids.add(i + 1, (Node) up[1]);29if (n.keys.size() <= CAP) return null;30int mid = n.keys.size() / 2;31int sep = n.keys.get(mid); // an internal separator MOVES up32Node right = new Node(); right.leaf = false;33right.keys.addAll(n.keys.subList(mid + 1, n.keys.size()));34right.kids.addAll(n.kids.subList(mid + 1, n.kids.size()));35n.keys.subList(mid, n.keys.size()).clear();36n.kids.subList(mid + 1, n.kids.size()).clear();37return new Object[]{sep, right};38}39static void add(int k) {40Object[] up = insert(root, k);41if (up != null) { // the root split: the tree grows in height42Node r = new Node(); r.leaf = false;43r.keys.add((Integer) up[0]); r.kids.add(root); r.kids.add((Node) up[1]);44root = r;45}46}47static List<Integer> range(int lo, int hi) {48Node n = root;49while (!n.leaf) { // descend to the leaf that would hold lo50int i = 0;51while (i < n.keys.size() && lo >= n.keys.get(i)) i++;52n = n.kids.get(i);53}54List<Integer> out = new ArrayList<>();55for (; n != null; n = n.next) { // then walk the chained leaves56for (int key : n.keys) {57if (key > hi) return out;58if (key >= lo) out.add(key);59}60}61return out;62}
Live state
max keys
3
Execution trace (dry run table)Show 15 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | max keys=3 | A B+ tree keeps every key in the leaves, which are chained left to right; internal nodes hold only separators. Nodes hold at most 3 keys here. |
| 2 | L14 | max keys=3 inserted=10 | 10 goes into the leaf [10] (1 of at most 3 keys). |
| 3 | L14 | max keys=3 inserted=20 | 20 goes into the leaf [10 20] (2 of at most 3 keys). |
| 4 | L14 | max keys=3 inserted=5 | 5 goes into the leaf [5 10 20] (3 of at most 3 keys). |
| 5 | L14 | max keys=3 inserted=6 | 6 goes into the leaf [5 6 10 20] (4 of at most 3 keys). |
| 6 | L21 | max keys=3 separator=10 | The leaf overflowed: split it into [5 6] and [10 20]. The separator 10 is COPIED up; it also stays in the right leaf, because leaves hold all the data. |
| 7 | L41 | max keys=3 separator=10 | The root split, so a new root with the single separator 10 is created: the tree is one level taller. |
| 8 | L14 | max keys=3 inserted=12 | 12 goes into the leaf [10 12 20] (3 of at most 3 keys). |
| 9 | L14 | max keys=3 inserted=30 | 30 goes into the leaf [10 12 20 30] (4 of at most 3 keys). |
| 10 | L21 | max keys=3 separator=20 | The leaf overflowed: split it into [10 12] and [20 30]. The separator 20 is COPIED up; it also stays in the right leaf, because leaves hold all the data. |
| 11 | L28 | max keys=3 separator=20 | The parent takes the new separator 20: 2 of at most 3 keys. |
| 12 | L14 | max keys=3 inserted=7 | 7 goes into the leaf [5 6 7] (3 of at most 3 keys). |
| 13 | L14 | max keys=3 inserted=17 | 17 goes into the leaf [10 12 17] (3 of at most 3 keys). |
| 14 | L14 | max keys=3 inserted=25 | 25 goes into the leaf [20 25 30] (3 of at most 3 keys). |
| 15 | L1 | keys=9 height=1 splits=2 | 9 keys in 3 leaves, tree height 1, 2 splits. |
Range scans
A **range scan** for [lo, hi] descends **once** to the leaf that would contain lo, then walks the chain to the right, reading keys until one exceeds hi. The cost is the height for the descent plus one page per leaf visited: O(log n + k / B) page reads for k results and B keys per page. A plain B-tree would have to climb back up and down through internal nodes to move between leaves.
The “Range scan [7, 20]” preset on the keys 10, 20, 5, 6, 12, 30, 7, 17, 25 returns [7, 10, 12, 17, 20] by reading 3 leaves after one descent. The “Range scan with no matches” preset ([21, 29] over 10, 20, 30, 40, 50) still reads 2 leaves: the one where 21 would be and the next one, which shows a key above 29.
1static List<Integer> range(int lo, int hi) {2Node n = root;3while (!n.leaf) { // descend once to the leaf that would hold lo4int i = 0;5while (i < n.keys.size() && lo >= n.keys.get(i)) i++;6n = n.kids.get(i);7}8List<Integer> out = new ArrayList<>();9for (; n != null; n = n.next) { // then walk the chained leaves10for (int key : n.keys) {11if (key > hi) return out;12if (key >= lo) out.add(key);13}14}15return out;16}
Best
O(log n)
Average
O(log n + k)
Worst
O(log n + k)
Space
O(1) beyond the output
B+ tree: keys in leaves, copied separators, chained leaves and range scans
Interactive · edit the inputB+ tree (data only in the leaves) · 1 node
leaf
leaf chain (left to right)
A B+ tree keeps every key in the leaves, which are chained left to right; internal nodes hold only separators. Nodes hold at most 3 keys here.
1static final int CAP = 3; // maximum keys per node2static class Node {3boolean leaf = true;4List<Integer> keys = new ArrayList<>();5List<Node> kids = new ArrayList<>(); // internal nodes only6Node next; // leaves only: the next leaf in key order7}8static Node root = new Node();9static Object[] insert(Node n, int k) { // null, or {separator, new right sibling} after a split10if (n.leaf) {11int i = 0;12while (i < n.keys.size() && n.keys.get(i) < k) i++;13if (i < n.keys.size() && n.keys.get(i) == k) return null;14n.keys.add(i, k);15if (n.keys.size() <= CAP) return null;16int mid = (n.keys.size() + 1) / 2;17Node right = new Node();18right.keys.addAll(n.keys.subList(mid, n.keys.size()));19n.keys.subList(mid, n.keys.size()).clear();20right.next = n.next; n.next = right; // splice the new leaf into the chain21return new Object[]{right.keys.get(0), right}; // the separator is COPIED up22}23int i = 0;24while (i < n.keys.size() && k >= n.keys.get(i)) i++; // an equal key lives in the right subtree25Object[] up = insert(n.kids.get(i), k);26if (up == null) return null;27n.keys.add(i, (Integer) up[0]);28n.kids.add(i + 1, (Node) up[1]);29if (n.keys.size() <= CAP) return null;30int mid = n.keys.size() / 2;31int sep = n.keys.get(mid); // an internal separator MOVES up32Node right = new Node(); right.leaf = false;33right.keys.addAll(n.keys.subList(mid + 1, n.keys.size()));34right.kids.addAll(n.kids.subList(mid + 1, n.kids.size()));35n.keys.subList(mid, n.keys.size()).clear();36n.kids.subList(mid + 1, n.kids.size()).clear();37return new Object[]{sep, right};38}39static void add(int k) {40Object[] up = insert(root, k);41if (up != null) { // the root split: the tree grows in height42Node r = new Node(); r.leaf = false;43r.keys.add((Integer) up[0]); r.kids.add(root); r.kids.add((Node) up[1]);44root = r;45}46}47static List<Integer> range(int lo, int hi) {48Node n = root;49while (!n.leaf) { // descend to the leaf that would hold lo50int i = 0;51while (i < n.keys.size() && lo >= n.keys.get(i)) i++;52n = n.kids.get(i);53}54List<Integer> out = new ArrayList<>();55for (; n != null; n = n.next) { // then walk the chained leaves56for (int key : n.keys) {57if (key > hi) return out;58if (key >= lo) out.add(key);59}60}61return out;62}
Live state
max keys
3
Execution trace (dry run table)Show 15 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | max keys=3 | A B+ tree keeps every key in the leaves, which are chained left to right; internal nodes hold only separators. Nodes hold at most 3 keys here. |
| 2 | L14 | max keys=3 inserted=10 | 10 goes into the leaf [10] (1 of at most 3 keys). |
| 3 | L14 | max keys=3 inserted=20 | 20 goes into the leaf [10 20] (2 of at most 3 keys). |
| 4 | L14 | max keys=3 inserted=5 | 5 goes into the leaf [5 10 20] (3 of at most 3 keys). |
| 5 | L14 | max keys=3 inserted=6 | 6 goes into the leaf [5 6 10 20] (4 of at most 3 keys). |
| 6 | L21 | max keys=3 separator=10 | The leaf overflowed: split it into [5 6] and [10 20]. The separator 10 is COPIED up; it also stays in the right leaf, because leaves hold all the data. |
| 7 | L41 | max keys=3 separator=10 | The root split, so a new root with the single separator 10 is created: the tree is one level taller. |
| 8 | L14 | max keys=3 inserted=12 | 12 goes into the leaf [10 12 20] (3 of at most 3 keys). |
| 9 | L14 | max keys=3 inserted=30 | 30 goes into the leaf [10 12 20 30] (4 of at most 3 keys). |
| 10 | L21 | max keys=3 separator=20 | The leaf overflowed: split it into [10 12] and [20 30]. The separator 20 is COPIED up; it also stays in the right leaf, because leaves hold all the data. |
| 11 | L28 | max keys=3 separator=20 | The parent takes the new separator 20: 2 of at most 3 keys. |
| 12 | L14 | max keys=3 inserted=7 | 7 goes into the leaf [5 6 7] (3 of at most 3 keys). |
| 13 | L14 | max keys=3 inserted=17 | 17 goes into the leaf [10 12 17] (3 of at most 3 keys). |
| 14 | L14 | max keys=3 inserted=25 | 25 goes into the leaf [20 25 30] (3 of at most 3 keys). |
| 15 | L1 | keys=9 height=1 splits=2 | 9 keys in 3 leaves, tree height 1, 2 splits. |
Index pages
A database index stores one node per **page** (commonly 4 KB to 16 KB). The **fan-out** of an internal node, how many children it has, is the page size divided by the size of one (key, child pointer) pair. With 4,096-byte pages and 16 bytes per entry that is 256 children; a leaf holds about as many (key, record pointer) entries.
The depth of the index then follows from n: each level multiplies the reach by the fan-out. With 255 keys per leaf and a fan-out of 256, a billion rows need about 3.9 million leaves, 15.3 thousand pages above them, then 60, then the root: 4 levels, so any row is reached in at most 4 page reads, usually fewer because the upper levels stay in memory.
1// levels of a B+ tree index for n rows: leaves hold leafCap keys, internal nodes have fanOut children2static int levels(long n, int leafCap, int fanOut) {3long pages = (n + leafCap - 1) / leafCap; // leaf pages4int levels = 1;5while (pages > 1) {6pages = (pages + fanOut - 1) / fanOut; // pages one level up7levels++;8}9return levels;10}11// levels(1_000_000_000L, 255, 256) == 4
B+ tree vs B-tree
Both are shallow, page-sized, multiway search trees. The differences decide which one a system uses. In a **B-tree** every key, with its data, can live in an internal node, so a lookup can stop early; in a **B+ tree** data lives only in the leaves, so every lookup reaches a leaf but internal nodes are smaller and the tree is shallower, and the leaf chain makes range scans and ordered iteration fast.
Databases and file systems overwhelmingly choose B+ trees for the range-scan behaviour and for the predictable cost of a lookup (always the same depth). B-trees appear where point lookups dominate and the data is small.
| B-tree | B+ tree | |
|---|---|---|
| Where data lives | in every node | only in the leaves |
| Internal node holds | keys with data and pointers | separator keys and pointers only |
| Fan-out for the same page size | lower | higher, so a shallower tree |
| Lookup cost | may stop at an internal node | always one leaf: the same depth every time |
| Range scan / full scan | needs an inorder traversal up and down | walk the leaf chain |
| Duplicates of a key | none across nodes | separators repeat keys that exist in leaves |
Practice ladder
Now try it yourself
Easy
- Search in a B+ tree (concept)
- Merge Sorted Pages
Medium
- Implement B+ tree insert
- Implement a B+ tree range query
Advanced
- Implement B+ tree deletion
- Bulk-load a B+ tree from sorted data
Key takeaways
- B+ tree leaves hold all the data; internal nodes hold only separators.
- A leaf split copies the separator up; an internal split moves it up.
- Leaves are chained, so a range scan needs one descent and then a walk along the chain.
- Fan-out is page size divided by entry size; a billion rows fit in about 4 levels.
- Databases choose B+ trees for range scans and uniform lookup cost.