Internals · B+ TreesAtlasDB V5 · B+ Treeb+ treeb-treepage splitmergeredistribute

B+ Tree Internals: Pages, Splits, Merges

A B+ tree is a set of numbered pages: one root, internal pages of separator keys and child page ids, and leaf pages of (key → row locator) entries chained left to right. Lookups read one page per level; inserts split a full page and push a separator up; deletes borrow from or merge with a sibling. Every rule exists to keep pages between half-full and full and all leaves at the same depth.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    The tree of pages from the previous lesson must survive inserts and deletes. Appending to a sorted array shifts everything after the insertion point — across pages, that means rewriting the file.

  2. Naive solution

    Insert into the correct leaf page and, when a page is full, rebuild the index from scratch. Delete by leaving holes.

  3. Why it breaks

    A rebuild is O(n) I/O per overflow, and holes never come back: a table with churn ends up with an index mostly empty, tall, and slow. Both violate the one property the lookup depends on: bounded height.

  4. Better idea

    Fix the problem locally. A full leaf is split in two halves and its parent gains one separator; if the parent is full, split it too. An under-full leaf borrows from a neighbour or merges with it and the parent loses a separator.

  5. Internal mechanism

    Pages have a capacity and a minimum occupancy (half); splits and merges keep every page inside those bounds, and because a split only adds height when the root itself splits, all leaves stay at the same depth. Leaves carry a next pointer, so a range is one descent plus a walk.

  6. Trade-offs

    An insert writes at least one page, sometimes three; pages sit between 50% and 100% full (about 70% on average); separators are duplicated into internal pages; concurrent splits need latches. In exchange: height 3–4, predictable cost per operation, ordered scans.

  7. Real database

    PostgreSQL btree (Lehman-Yao variant with right-links and high keys; leaves store TIDs) and InnoDB (clustered: the leaf *is* the row; secondary leaves hold the primary key).

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

Why it makes lookups fast

The tree is short because each page holds hundreds of keys: each level divides the key space by ~200, so three levels cover millions of rows. A lookup reads one page per level and then the row — three or four page reads however large the table is.

The top levels are shared by every lookup, so they are always in memory; in practice a lookup costs one or two real disk reads.

The page-oriented view

Forget the picture of a tree of nodes. A B+ tree is a file of fixed-size pages; the "tree" is the pattern of page ids they store. Internal pages hold k separator keys and k+1 child page ids. Leaf pages hold entries (key → row locator) in key order plus the page id of the right sibling. The root is whichever page the metapage says it is.

Below is a complete tree with capacity 3 keys per page (real pages hold ~200; small capacities make splits visible). Read it the way the engine does: page ids, not arrows.

A B+ tree of height 3, max 3 keys per page, holding 14 keys
PAGE 0  (meta)      root = 9

PAGE 9  (root)      keys [40, 80]           children → 3, 7, 8

PAGE 3  (internal)  keys [20]               children → 1, 2
PAGE 7  (internal)  keys [60]               children → 4, 5
PAGE 8  (internal)  keys [90]               children → 6, 10

PAGE 1  (leaf)   [10→heap(1,2)  15→heap(1,7)]                 next → 2    2/3 used
PAGE 2  (leaf)   [20→heap(2,5)  30→heap(2,1)  35→heap(3,0)]   next → 4    3/3 used (full)
PAGE 4  (leaf)   [40→heap(5,0)  50→heap(6,2)]                 next → 5    2/3
PAGE 5  (leaf)   [60→heap(7,1)  70→heap(7,4)]                 next → 6    2/3
PAGE 6  (leaf)   [80→heap(10,0) 85→heap(10,3)]                next → 10   2/3
PAGE 10 (leaf)   [90→heap(11,2) 95→heap(11,5) 99→heap(12,0)]  next → ∅    3/3

invariant: child i of an internal page holds keys < keys[i]; child i+1 holds keys ≥ keys[i]
invariant: every leaf at depth 3; every non-root page holds ≥ ⌈3/2⌉ = 2 keys

Search: one page per level

A lookup for 70 reads the root (page 9): 40 ≤ 70 < 80, so child 1 → page 7. Page 7: 60 ≤ 70, so child 1 → page 5. Page 5 is a leaf; binary search finds 70 at slot 1 → heap(7,4). Three index pages plus one heap page. Each page decision is a binary search over its keys — roughly 8 comparisons on a 200-key page — but comparisons are not what you wait for.

Height is what you wait for, and height is logarithmic with base = fanout. With 200 keys per page: 1 level for ≤ 200 entries, 2 for ≤ 40,000, 3 for ≤ 8 million, 4 for ≤ 1.6 billion. The root and the second level are a few hundred pages at most and stay in the buffer pool, so the typical lookup is one real read for the leaf and one for the heap row.

SEARCH 70
read PAGE 9 (root)    keys [40, 80]   40 ≤ 70 < 80  → child page 7      compares 2
read PAGE 7           keys [60]       60 ≤ 70       → child page 5      compares 1
read PAGE 5 (leaf)    keys [60, 70]   70 at slot 1  → heap(7,4)         compares 2
read heap page 7, slot 4 → the row

pages read: 3 index + 1 heap · height 3 · comparisons 5

Insert and split

Insert 33: descend to page 2, which holds [20, 30, 35] — full. The page cannot take a fourth key, so it splits: the lower half stays, the upper half moves to a freshly allocated page, the sibling chain is relinked around the new page, and the first key of the new page is copied up into the parent as a separator. The parent (page 3) had one key and takes a second. Three pages written: the two leaves and the parent.

If the parent is also full, it splits too — but internal splits move the middle key up rather than copying it, because an internal key is only a guide and does not need to exist anywhere else. A split of the root allocates a new root with a single separator, and the tree grows one level. That is the only way height increases, and it is why all leaves are always at the same depth: growth happens at the top, never at the bottom.

INSERT 33 into the tree above
read PAGE 9  keys [40, 80]        33 < 40      → child page 3
read PAGE 3  keys [20]            20 ≤ 33      → child page 2
read PAGE 2  keys [20, 30, 35]    would become [20, 30, 33, 35] = 4 keys > max 3 → SPLIT
  write PAGE 2   keys [20, 30]                        next → 11
  write PAGE 11  keys [33, 35]   (new)                next → 4
  separator 33 (first key of PAGE 11, copied) goes up to PAGE 3
write PAGE 3  keys [20, 33]       children → 1, 2, 11

pages read 3 · pages written 3 · height unchanged (3)

if PAGE 3 had been full: split it, MOVE the middle key up to PAGE 9;
if PAGE 9 had been full: split it, allocate PAGE 12 as new root → height 4

Delete: borrow or merge

Delete 15 from page 1 ([10, 15]): the page drops to 1 key, below the minimum of 2. The engine first tries to borrow: if a sibling under the same parent has more than the minimum, one entry moves across and the separator in the parent is updated to the new first key. Here page 2 holds exactly 2, so it cannot spare one. The alternative is merge: page 1 absorbs page 2, the separator 20 is dropped from page 3, page 2 is freed, and the sibling chain skips it.

The parent may now be under-full too, and the same rule applies one level up: borrow from a sibling through the parent (rotating a separator down and another up) or merge and pull the separator down. If the root loses its last separator, its only child becomes the root and the tree shrinks by one level. Many production engines never merge eagerly — PostgreSQL only deletes a leaf page once it is completely empty, during VACUUM — because half-empty pages are cheap and merges are not.

Separators do not have to be real keys. Deleting 40 from the leaf leaves the separator 40 in page 9 untouched: it still divides the key space correctly. This is why a B+ tree can carry keys in its internal pages that no longer exist in any leaf.

DELETE 15
read PAGE 9  keys [40, 80]     15 < 40   → child page 3
read PAGE 3  keys [20]         15 < 20   → child page 1
read PAGE 1  keys [10, 15]     remove 15 → [10]   1 < min 2 → UNDERFLOW
  right sibling PAGE 2 [20, 30] has exactly min → cannot borrow → MERGE
  write PAGE 1  keys [10, 20, 30]     next → 4     (absorbs PAGE 2)
  write PAGE 3  keys []               children → 1  (separator 20 dropped)  ← under-full
  free  PAGE 2
PAGE 3 has 0 keys < min 1 → repair at the parent level:
  left/right sibling of 3 under PAGE 9: PAGE 7 [60] has min → cannot borrow → MERGE
  write PAGE 3  keys [40, 60]         children → 1, 4, 5   (separator 40 pulled DOWN from root)
  write PAGE 9  keys [80]             children → 3, 8
  free  PAGE 7

Range scans along the leaf chain

WHERE k BETWEEN 25 AND 70 does one descent — to the leaf that would contain 25 — and then never touches an internal page again. It emits entries from that leaf, follows next to the right sibling, and continues until it meets a key above 70. The cost is height plus the number of leaves the range spans, and those leaves are neighbours, so a large range reads pages in nearly sequential order.

This is the property that makes ORDER BY k, MIN(k), MAX(k), k > ? and LIKE 'abc%' index-friendly, and it is what a B-tree (values in internal nodes, no leaf chain) and a hash index both lack. ORDER BY k DESC needs a backward walk, which is why real leaves also carry a prev pointer.

RANGE [25, 70]
read PAGE 9  → PAGE 3 → PAGE 2 (leaf holding 25's position)
read PAGE 2  [20, 30, 35]    collect [30, 35]        follow next → 4
read PAGE 4  [40, 50]        collect [40, 50]        follow next → 5
read PAGE 5  [60, 70]        collect [60, 70]        next key would be 80 > 70 → stop

result [30, 35, 40, 50, 60, 70] · pages read 5 · leaf pages traversed 3 · no re-descent

B+ versus B: why keys are duplicated

A B-tree stores each key exactly once, with its value, wherever it happens to land — including in internal nodes. A B+ tree stores every key in a leaf and copies some of them into internal pages as separators. The duplication buys three things: internal pages hold only keys and page ids, so their fanout is maximal; every lookup ends at the same depth; and all entries are in one sorted, chained sequence, so ranges are a walk rather than an in-order traversal through internal nodes.

The cost is a few percent of extra key storage in the internal levels — which for a 200-key page is under 1% of the tree. No storage engine that targets disk or SSD uses the B-tree variant.

B-tree vs B+ tree
B-treeB+ tree
Where values liveany nodeleaves only
Keys in internal nodesreal keys with valuescopied separators (guides)
Internal fanoutlower — values take spacemaximal — keys + page ids only
Lookup depthvaries: may stop earlyalways height
Range scanin-order traversal through internal nodeswalk the leaf chain
Used by disk enginesrarelyPostgreSQL, InnoDB, SQLite, Oracle, SQL Server

PostgreSQL btree: Lehman-Yao, high keys, TIDs

PostgreSQL implementation

PostgreSQL's btree access method is a Lehman-Yao B+ tree. Every page — internal and leaf — carries a right-link to its right sibling and a high key, the upper bound of what the page may contain. A reader that descends into a page and finds its key above the high key knows a split happened underneath it and simply follows the right-link; no lock on the parent is needed while reading. Writers hold a short lock on the page being split and fix the parent afterwards.

Leaf entries are (key, TID) where TID = (heap block, offset): the index never contains the row. Since PostgreSQL 12, duplicate keys are ordered by TID and since 13 they are deduplicated into posting lists, which shrinks indexes on low-cardinality columns. Deleted entries are removed lazily: a leaf page is unlinked only when VACUUM finds it empty, so a churned index needs REINDEX or pg_repack to recover space. Page 0 is the metapage naming the root and the tree height; pageinspect shows all of it.

Looking at the real pages.
1CREATE EXTENSION IF NOT EXISTS pageinspect;
2SELECT * FROM bt_metap('users_email_idx'); -- root page id, level = height - 1
3SELECT * FROM bt_page_stats('users_email_idx', 1); -- type, live_items, free_size, btpo_next (right-link)
4SELECT itemoffset, ctid, data
5FROM bt_page_items('users_email_idx', 1) LIMIT 5; -- (key, TID) entries; item 1 is the high key

InnoDB: the leaf is the row

MySQL / InnoDB implementation

InnoDB tables are B+ trees keyed by the primary key — the clustered index — and the leaf pages contain the full rows, not locators. A primary-key lookup therefore ends at the leaf with the row in hand: height reads, no heap fetch. Pages are 16 KB by default, roughly doubling the fanout compared with 8 KB.

Secondary indexes are separate B+ trees whose leaf entries hold the secondary key plus the primary key rather than a physical locator. A secondary lookup is two descents — secondary tree, then clustered tree — and a wide primary key is paid for in every secondary index. Rows move when the clustered page splits, which is why the leaf cannot store a physical position. See InnoDB Internals: Clustered Index, Buffer Pool, Redo, Undo, Locks and Physical Layouts Compared: Heap + Secondary Index vs Clustered Index for the consequences.

Key points

  • A B+ tree is a set of pages that reference each other by page id: a root, internal pages of separators and child ids, leaf pages of (key → locator) chained left to right.
  • Lookup cost is the height — one page per level — and height is log with base = fanout (~200 per 8 KB page), so 3–4 levels cover billions of entries.
  • A full leaf splits in half and copies its new first key up as a separator; a full internal page splits and moves its middle key up; a root split adds a level.
  • An under-full page borrows from a sibling or merges with one; a merge can cascade up and shrink the tree.
  • Leaf sibling pointers make ranges, ORDER BY, MIN/MAX and prefix matches one descent plus a walk.
  • All keys live in leaves; internal keys are duplicated guides — that is the B+ in the name and the reason internal fanout is maximal.

B+ tree visualizer

B+ tree, page by page
Pages hold at most 4 keys (real pages hold ~200 — small on purpose so splits happen). Every operation is replayed one page access at a time. Highlighted: read (blue) and written (red).
#1 leaf 3/410 │ 20 │ 25#5 leaf 2/430 │ 40#2 leaf 3/450 │ 60 │ 70#4 leaf 3/480 │ 85 │ 90#3 root30 │ 50 │ 80
Tree height
2
Pages read
Pages written
Comparisons
Leaf pages traversed
Educational simulation — tiny pages, integer keys, no concurrency, no latches. Pages read / written count distinct pages per operation; a real engine would find the top levels in the buffer pool.
Try in order: Search 45 (root → child → leaf, one page per level), Insert 45 then keep inserting until a leaf shows 4/4 and the next insert splits it, Range 25…70 to watch the walk along the leaf chain, then Delete keys from one leaf until it underflows and borrows or merges.
Internal pages hold separators and child page ids; leaf pages hold (key → heap(page,slot)) and a `next` pointer. A separator is copied from the first key of the right leaf, so every key also appears in a leaf — that is what makes this a B+ tree rather than a B-tree.
Run an operation to replay it step by step.

When to use — and when not

Use it when
  • This structure fits any index on a disk- or SSD-backed engine that must answer equality, ranges and ordering on the same key.
  • Primary keys and unique constraints, where the structure also enforces uniqueness at the leaf.
  • Read-heavy and mixed workloads where predictable lookup cost matters more than raw write throughput.
Avoid it when
  • This structure is the wrong choice when writes dominate and random leaf updates are the bottleneck — a log-structured tree trades read cost for sequential writes; see Storage Engine Comparison: B+ Tree vs LSM Tree.
  • Equality-only lookups on keys that will never be ranged or sorted, where a hash index is flatter.
  • In-memory structures where a pointer chase costs nanoseconds and fanout buys nothing.

Failure modes

  • Index bloat: deleted entries leave half-empty pages that are never merged, so the tree stays tall and wide; see PostgreSQL in Production: Connections, VACUUM, Partitioning, Replication.
  • Monotonic keys (sequences, timestamps) concentrate every insert on the rightmost leaf — fine on a single writer, a hot page under many.
  • Wide keys collapse fanout: a 200-byte key gives ~40 keys per page and a tree two levels taller.
  • Reasoning about the index as "sorted" and forgetting the heap fetch: a range that returns 30% of a table is 30% of the heap at random.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.