Cairn
data structures · B-Tree's database-index cousin · O(log n)

back to Node-Linked Trees

B+ Tree

A B-tree stores real data in every node — internal nodes and leaves both hold actual keys, and a search can succeed the moment it hits a match partway down. A B+ tree changes one rule: internal nodes hold copies of keys used purely to route a search, never the data itself, and every real key lives in a leaf. Search always walks all the way to a leaf, even when it happens to pass a matching value on the way — that sounds like it should be strictly worse, and for a single lookup it does give something up: a B-tree search can terminate the instant it hits a match anywhere on the way down, while a B+ tree's always costs exactly the tree's height in node visits, no early exit possible. What it buys back is bigger than that cost: since nothing but routing keys clutters an internal node, more keys fit per node at a given page size, so the tree gets shallower for the same n. And because every leaf holds real data and nothing but real data, the leaves can be threaded together in a singly linked list, left to right — turning a range query ("every key between 40 and 80") from a scatter of independent searches into one descent followed by a straight walk along that list. That's the whole reason B+ trees, not plain B-trees, are what MySQL's InnoDB, PostgreSQL, and SQLite actually put behind a database index (B-Tree's own page names this without building it — this page is that build).

Try it

The tree below loads the same 10, 20, 30, … 90 sequence B-Tree's demo uses, for a direct side-by-side: same nine values, same minimum degree t = 2 (1 to 3 keys per node), same starting height of 3 — but every leaf here (the bottom row) is drawn with a dashed link to the leaf immediately to its right, the actual linked list the reference implementation below maintains, not just a visual suggestion. Leaves are shaded to tell them apart from the internal routing row above. Try inserting 75: the leaf holding 70, 80, 90 is full, so it splits — watch the log name the promoted separator (90) and confirm it's a copy: it moves up into the parent and stays behind in the leaf, unlike B-Tree's split, which moves its middle key up and out. Then insert 15 (lands in the first leaf, no split) and insert 85 (splits the now-full 70, 75, 80 leaf again). Now try the range query below for 40 to 80: the log reports exactly how many internal nodes and how many leaves the scan touched — compare that count to what six separate single-key searches would cost, printed alongside it. Finally, delete 90: its leaf is down to one key, the legal minimum, so it borrows from its left sibling (which still has a key to spare) rather than merging — watch the log show the sibling's own last key moving over (not the old separator) and the parent's separator being recomputed from the leaf's new first key afterward, the specific mechanic the Pitfalls section below verifies two ways to get wrong.

Loaded by inserting 10 through 90 in steps of 10 — same shape as B-Tree's own demo, but every leaf (shaded) links to the next. Try inserting 75, then a range query from 40 to 80.

Splitting a full leaf: copy up, not move up

B-Tree's split cuts a full node's top t - 1 keys into a new sibling and moves the single key between the two halves up into the parent — that key leaves the node it came from entirely. A B+ tree's leaf split can't do that: every key has to stay findable in a leaf, including whichever one ends up marking the boundary. Like B-Tree, this page's algorithm splits a child before descending into it, the moment it's found to already hold 2t - 1 keys — the new key being inserted isn't part of the leaf yet and plays no role in the split itself. So a full leaf's existing 2t - 1 keys alone split into two leaves of roughly even size (this page's reference implementation splits at Math.ceil(n / 2), giving the left leaf the larger half when the count is odd), and the new right leaf's own smallest key is copied up into the parent as the routing separator — the leaf keeps it too. Only after that split does the walk decide which of the two resulting leaves the new key actually belongs in. Concretely, verified against the shipped script: the full 70, 80, 90 leaf splits on Math.ceil(3 / 2) = 270, 80 stay, 90 moves to the new leaf alone — and 90 (the new leaf's own smallest, and only, key) is promoted as a copy. Only then does 75 get compared against that new separator (75 < 90) and land in the left leaf, which is why the leaf that ends up holding 75 is 70, 75, 80, not an even half of four. The new leaf is spliced into the linked list at the same time (old leaf's next becomes the new leaf, new leaf's next becomes whatever the old leaf used to point to), so the moment the split finishes, a linked-list walk starting anywhere still visits every leaf in order without needing to know a split just happened.

Splitting a full internal node: identical to a B-Tree

The copy-vs-move distinction only applies to leaves. An internal node in a B+ tree holds nothing but routing separators — no separator "belongs" to any one subtree the way a leaf's key belongs to that leaf — so splitting one is exactly B-Tree's own operation, move the true middle key up and out, no copy, no leaf, no linked list to maintain. Both algorithms use the same proactive, split-before-descending discipline B-Tree's own page describes in full: check whether the child about to be descended into is full before stepping into it, splitting there if so, which means (leaf or internal) a node is never entered already full and the whole walk stays a single top-down pass with no backtracking.

Search and range scan: one descent, then a straight walk

Search behaves like B-Tree's with one change: an internal node's keys are pure routing, so a match there doesn't end the search — the walk continues to a leaf regardless, and the leaf is where the real comparison happens. A range query for every key between lo and hi reuses that same single descent to find the leaf holding lo, then walks the linked list rightward, collecting matches and stopping the first time a leaf's own maximum key exceeds hi — no further internal-node reads at all past the first descent. Measured directly against this page's own loaded-and-modified demo tree (12 values — the "Try it" sequence above, through inserting 85 but before either delete — height 3): a range query for 40 to 80 touches 2 internal nodes (the one-time descent) and 4 leaves — 6 node visits total for 6 results. The same 6 values looked up as six independent single-key searches, B-Tree-style, cost 18 node visits — each of those 6 searches re-reads the same 2 internal nodes from the root, 6 times each, where the linked range scan reads them once. The gap only widens as node count grows or the tree gets deeper; the live "Range query" button above runs this exact count against whatever the demo tree currently looks like, not a fixed canned number.

Delete: a leaf borrow moves real data, a separator is only ever recomputed from it

B-Tree's borrow moves the parent's own separator key down into the shrinking child and pulls the sibling's outermost key up to replace it — legal there because that separator is real data, free to relocate. A B+ tree's separator is a copy; the leaf-level operations have to work differently, and getting this wrong is the specific, verified failure mode this page's Pitfalls section below documents with real numbers:

  1. Borrow from a left leaf sibling with keys to spare: move the sibling's own last key over to become the shrinking leaf's new first key — real data changing hands, same as any other leaf mutation. Only after that move does the parent separator get touched, and it's not swapped for something pulled from the sibling — it's recomputed outright as the leaf's own new first key (the value that just moved in). Symmetrically, borrowing from a right sibling moves that sibling's first key onto the shrinking leaf's end, and the separator is recomputed as the sibling's own new (post-move) first key.
  2. Merge two under-minimum leaf siblings: concatenate their keys directly into one leaf — no separator folds in, unlike B-Tree's merge, because there's no data value living in the parent to fold; the parent only loses the routing key and the pointer to the now-absorbed sibling. The linked list needs its own fix in the same step: the surviving leaf's next has to skip over the absorbed one, pointing at whatever the absorbed leaf used to point to.
  3. Borrow or merge at an internal node (the child needing help isn't a leaf) is unchanged from B-Tree — pure routing keys move and merge under the identical rules described in B-Tree's own Delete section, because nothing about them is tied to a specific leaf's data the way a leaf borrow's moved key is.

Verified directly against the shipped script's own state: continuing from the "Try it" sequence above (load 10–90, insert 75, insert 15, insert 85), deleting 90 finds its leaf down to a single key — the bare minimum — with a left sibling 80, 85 that has one to spare. The borrow moves 85 (the sibling's real last key) over, the leaf briefly holds 85, 90 before the delete removes 90, leaving just 85, and the separator between them — previously 90, the value that no longer marks anything — is recomputed as 85, the leaf's actual new minimum. A separate verified case (not the default "Try it" path, but checked directly the same way): starting from a fresh load and deleting 30, then 10, then 60, then 40 drives a leaf down to the bare minimum with no sibling able to spare a key, forcing a merge instead of a borrow — the leaf holding 40 and its neighbor holding 50 combine into one 40, 50 leaf, the parent's separator between them is dropped entirely (not folded in), and the absorbed leaf's predecessor is re-pointed past it in the linked list — confirmed by walking the full linked list afterward and getting the exact sorted remainder, not just checking the merged leaf's own contents.

Reference implementation

class BPlusTree {
  #t;
  #root;
  constructor(t = 2) {
    this.#t = t;
    this.#root = { keys: [], children: [], leaf: true, next: null };
  }

  #splitLeaf(parent, i) {
    const y = parent.children[i];                    // full leaf, 2t - 1 keys
    const z = { keys: y.keys.splice(Math.ceil(y.keys.length / 2)), children: [], leaf: true, next: y.next };
    y.next = z;
    const sep = z.keys[0];                            // COPY up — stays in z too
    parent.children.splice(i + 1, 0, z);
    parent.keys.splice(i, 0, sep);
  }

  #splitInternal(parent, i) {                          // identical to B-Tree's own split
    const t = this.#t;
    const y = parent.children[i];
    const z = { keys: y.keys.splice(t, t - 1), children: [], leaf: false, next: null };
    const midKey = y.keys.pop();                       // MOVED up — no copy, pure routing key
    z.children = y.children.splice(t, t);
    parent.children.splice(i + 1, 0, z);
    parent.keys.splice(i, 0, midKey);
  }

  #insertNonFull(node, k) {
    if (node.leaf) {
      let i = node.keys.length - 1;
      while (i >= 0 && k < node.keys[i]) i--;
      node.keys.splice(i + 1, 0, k);
      return;
    }
    let i = 0;
    while (i < node.keys.length && k >= node.keys[i]) i++;   // >= routes a match RIGHT — see Search
    const child = node.children[i];
    if (child.keys.length === 2 * this.#t - 1) {
      child.leaf ? this.#splitLeaf(node, i) : this.#splitInternal(node, i);
      if (k >= node.keys[i]) i++;
    }
    this.#insertNonFull(node.children[i], k);
  }

  insert(k) {
    if (this.search(k)) return false;                  // duplicate — same guard as B-Tree
    if (this.#root.keys.length === 2 * this.#t - 1) {
      const newRoot = { keys: [], children: [this.#root], leaf: false, next: null };
      this.#root = newRoot;
      newRoot.children[0].leaf ? this.#splitLeaf(newRoot, 0) : this.#splitInternal(newRoot, 0);
    }
    this.#insertNonFull(this.#root, k);
    return true;
  }

  #findLeaf(k, node = this.#root) {
    if (node.leaf) return node;
    let i = 0;
    while (i < node.keys.length && k >= node.keys[i]) i++;
    return this.#findLeaf(k, node.children[i]);
  }

  search(k) { return this.#findLeaf(k).keys.includes(k); }   // always reaches a leaf — see Search

  range(lo, hi) {
    const out = [];
    let leaf = this.#findLeaf(lo);
    while (leaf) {
      for (const k of leaf.keys) if (k >= lo && k <= hi) out.push(k);
      if (leaf.keys.length && leaf.keys[leaf.keys.length - 1] > hi) break;
      leaf = leaf.next;                                 // the whole payoff — no re-descending
    }
    return out;
  }

  // ---- delete: borrow moves real leaf data, merge drops the separator, both fix `next` ----
  #borrowLeaf(node, idx, fromLeft) {
    if (fromLeft) {
      const child = node.children[idx], sibling = node.children[idx - 1];
      child.keys.unshift(sibling.keys.pop());
      node.keys[idx - 1] = child.keys[0];               // recomputed, not swapped — see Pitfalls
    } else {
      const child = node.children[idx], sibling = node.children[idx + 1];
      child.keys.push(sibling.keys.shift());
      node.keys[idx] = sibling.keys[0];
    }
  }
  #mergeLeaf(node, idx) {
    const child = node.children[idx], sibling = node.children[idx + 1];
    child.keys.push(...sibling.keys);                   // no separator folded in — see Pitfalls
    child.next = sibling.next;
    node.keys.splice(idx, 1);
    node.children.splice(idx + 1, 1);
  }
  // #borrowInternal / #mergeInternal: identical to B-Tree's #fill helpers, omitted here — see
  // b-tree.html's own reference implementation for the full internal-node borrow/merge code.
}

Verified from scratch before writing a line of this page's prose: 500 trials of 60 randomized mixed insert/delete/search/range operations each (30,000 operations total, values drawn from a small range to force frequent splits, merges, and borrows) against a plain JavaScript Set-plus-sorted-array model, checking after every single operation — insert and delete results (present/absent) matching the model, five random search probes agreeing with the model, a range query against a random [lo, hi] window matching an independent filter of the model every fifth step, and a full structural invariant check (every node's key count within [t - 1, 2t - 1] outside the root, every internal node's child count exactly one more than its key count, every leaf at the identical depth, every node's own keys strictly sorted, and — the B+-tree-specific check the other tree pages don't need — the leaf linked list itself walked start to finish and confirmed strictly sorted with no gaps or duplicates against the in-order traversal it should match). Zero mismatches across all 30,000 operations. Re-verified against the exact shipped functions (not the scratch prototype) through a hand-rolled fake-DOM harness driving real Insert/Delete/Search/Range-query clicks: a further 300 trials of 50 mixed operations each (15,000 operations) against the same model, reading results back out of the shipped log text rather than a return value, found zero mismatches; the "Try it" sequence above traced exactly as described, and the range-query node-visit counts (2 internal + 4 leaves = 6, versus 18 for six independent searches) matched the scratch reference's own count precisely.

Pitfalls

Reusing B-Tree's borrow — move the old separator down, promote the sibling's key up — looks plausible and corrupts the tree almost immediately. Swap this page's #borrowLeaf for a literal copy of B-Tree's #borrowFromLeft (move the parent's separator into the child, replace it with the sibling's popped key) and run the same same-shaped stress harness against the exact shipped functions (200 trials, 60 mixed insert/delete/search operations each): 119 of 200 trials fail an invariant, a model mismatch, or throw outright, most within the first handful of operations. The reason is structural, not a rare edge case: the "separator" being moved down is a copy with no other owner, so pushing it into the child doesn't insert real data that belonged there — it silently duplicates a value that's still sitting, correctly, somewhere in the leaf that originally copied it up, or in a case checked directly, plants a value in a leaf that never had it and never should. An in-order/linked-list walk of the resulting tree comes back with a value out of order or duplicated almost every time, not "eventually, under some rare sequence."

Moving the real data correctly but forgetting to recompute the separator afterward doesn't corrupt the tree's shape at all — it corrupts search for the exact value that just moved. This is the more dangerous of the two bugs, because every structural invariant this page checks (key counts, child counts, leaf depth, sorted keys, even the linked-list walk) stays perfectly clean — the leaf really does hold the right keys in the right order. Checked directly against the exact shipped functions (not just a scratch reimplementation): patching this one broken variant in (leaf borrow moves the sibling's key correctly, then simply skips updating node.keys[idx - 1]/node.keys[idx] afterward), running 800 randomized 30-operation trials, then clicking Search for every value the model still says is present finds the shipped Search button wrongly reporting "not found" for a value that's really in the tree in 165 of 800 trials — the stale separator now sits on the wrong side of the boundary it's supposed to describe, so a search for the just-moved value follows the routing key straight past the leaf that actually holds it. Nothing about the tree's own shape gives this away; only checking search's answer against an independent model catches it, which is exactly why this page's verification harness checks search results after every single operation rather than only at the end of a run.

Where B+ trees show up

Complexity

Time: identical asymptotic shape to B-Tree — height at most log_t((n + 1) / 2), so search, insert, and delete all cost O(log_t n) node visits, each visit doing O(t) work scanning that node's keys. A range query returning k results costs O(log_t n + k / t): one descent to find the start, then O(k / t) leaf reads via the linked list, with zero further internal-node reads regardless of how large k gets — the concrete 2-internal/4-leaf split measured above for one small example, not just the asymptotic shape. Space: O(n), same as B-Tree, plus one extra pointer per leaf for the linked list — negligible next to the O(t) keys and child pointers each node already carries.

See Choosing a Search Tree for how B+ Tree fits alongside B-Tree and the site's other disk/cache-aware entries.