B-Trees

By the end of this lesson you will be able to explain exactly why databases and filesystems store their indexes in B-trees instead of red-black trees, trace bTreeSearch, bTreeSplitChild, bTreeInsert and every one of bTreeDelete's six cases line by line, count real disk accesses as the unit of cost, and implement a fully working, generic B-tree in Dart.

0. Why B-trees exist: the disk-access model

Imagine your "memory" is actually a library on a different floor: RAM is the notepad on your desk (grab anything in nanoseconds), but the real data lives on disk — a spinning platter with a mechanical arm that has to physically move to the right track and wait for the disk to rotate into position before it can read anything. That physical trip takes about 10 milliseconds. A single RAM access takes about 50 nanoseconds. That is a gap of more than five orders of magnitude (about 200,000 times) — like the difference between glancing at your notepad (a few seconds) and walking to a library across town and back (well over a week) for a single fact. Every algorithm in this lesson exists to make that trip as rare as possible.

Disks don't hand you one byte at a time — they read and write whole pages (typically 211–214 bytes) in a single trip. The standard disk-access model makes this explicit with two primitives: diskRead(x) brings a page (a tree node) into memory, and diskWrite(x) saves a modified page back. Every algorithm in this lesson is written to keep at most O(1) pages in memory at once, and running time is measured by two numbers: (1) the number of disk accesses, and (2) the CPU time (which is usually a lower-order concern once you're paying 10ms per disk trip).

Here is that model as Dart: diskRead and diskWrite become two counters, so every procedure below can report its disk cost.

Input size → what is feasible. n = 109 keys on disk: a balanced binary tree needs about 29 page reads per lookup (≈ 0.3 s at 10 ms each), a B-tree with t = 1001 needs at most 2 (the root stays in memory) — which is why the model counts pages, not CPU steps.

A B-tree is a balanced search tree designed exactly around this cost model: instead of a binary tree (2 children per node, like a red-black tree), a B-tree node is sized to fill one whole disk page and can have hundreds or thousands of children. Packing more branching into every single disk trip means the tree's height — and therefore the number of trips a search needs — shrinks dramatically. Concretely: with a branching factor of 1001 and height 2, a B-tree can index over one billion keys while the root stays cached in memory, so any single key can be found in at most 2 disk accesses. A balanced binary tree over a billion keys would need roughly 30 levels — 30 disk accesses per search, fifteen times slower.

B-trees were introduced by Rudolf Bayer and Edward McCreight in the early 1970s; what the “B” stands for is folklore, not documented fact. An older relative, the 2-3 tree (every internal node has 2 or 3 children), is not the t = 2 case: minimum degree t = 2 gives nodes with 2, 3 or 4 children, i.e. the 2-3-4 tree — the one that corresponds to red-black trees.

Real database engines (PostgreSQL, MySQL/InnoDB, SQLite) build their indexes almost exclusively on B-trees (technically usually the B+-tree variant, which keeps all payload in the leaves; see the B⁺-tree question below) for exactly this reason — and it is why "explain how a database index works" interview questions almost always want a B-tree answer.

1. Definition of a B-tree

Think of a B-tree node as a filing folder that can hold several sorted tab-dividers (the keys) and, behind each gap between dividers, a pointer to a whole sub-folder (a child) covering exactly the range of values between those two dividers. A folder with n dividers always has exactly n + 1 sub-folder pockets — one before the first divider, one between each pair, and one after the last.

Every node x has these attributes (named the way the Dart code below stores them; everything is 0-indexed):

Every B-tree is built around one number, its minimum degree t ≥ 2 (also called the branching factor), which bounds how empty or full any node may get:

t = 2 is the smallest legal minimum degree, and it gives every internal node between 2 and 4 children — exactly a 2-3-4 tree. Real databases use much larger t (tens to thousands) to make each node exactly one disk page.

The same rules as code (a node with n keys has n + 1 children, so t … 2t children):

Why is t = 1 illegal? With t − 1 = 0, the "at least t − 1 keys" rule becomes "at least 0 keys", which puts no floor at all on how empty a node can be — you could have a node with 1 key and 2 children forever, degenerating into an ordinary unbalanced binary search tree with none of the height guarantees a B-tree is supposed to provide. The minimum-degree rule only does useful work once t ≥ 2.

Height bound. If n ≥ 1, then for any n-key B-tree T of height h and minimum degree t ≥ 2: h ≤ logt((n+1)/2). The proof counts the fewest possible nodes at each depth (the root contributes ≥ 1 key; every other node contributes ≥ t − 1 keys, and there are at least 2ti-1 nodes at depth i for i ≥ 1), sums a geometric series, and solves for h. Because the branching factor t sits inside a logarithm's base, doubling or tripling it shrinks the height dramatically — this is the entire reason B-trees beat red-black trees for disk-resident data. Here is the bound in numbers for n = 109 keys (the root counts as depth 0; checked in verify/c18.dart):

minimum degree t2101001001
largest legal height h = ⌊logt((n+1)/2)⌋28842

A red-black tree on the same keys needs about 30 levels. Explore the exact minimal-node-count tree the proof constructs (each • is a key; the highlighted level is the one being counted):

The height bound as code, computed instead of quoted: the loop adds up the fewest keys a height-h tree can have (the same count the proof makes), it is compared with the closed form 2th − 1, and maxHeight inverts it to ⌊logt((n+1)/2)⌋ with exact integers (the floating-point log version is shown only to warn you):

Input size → what is feasible. n = 1012 keys, t = 2 → h ≤ 38; t = 103 → h ≤ 3; maxHeight runs at most ~40 loop steps, so it is O(logt n) for any n that fits in a 64-bit int (keep (n+1)·t < 9.2·1018).

A B-tree of minimum degree t keeps every node between t−1 and 2t−1 keys (the root is exempt from the lower bound), keeps ALL leaves at the same depth, and bounds height by O(logt n) — a factor of lg t shorter than a comparable red-black tree, which is exactly what makes disk accesses rare.

2. Basic operations

Three conventions run through every algorithm below: the root is always assumed to already be in memory (no diskRead needed to reach it, but a diskWrite is still needed whenever it changes); every node passed as a parameter has already been read from disk by its caller; and every algorithm makes a single downward pass from the root — it never has to back up and revisit a node it already left, which is exactly what keeps disk accesses to O(h).

bTreeCreate(t)

Opening a brand-new, completely empty filing cabinet: you don't do any sorting or filing yet, you just set one empty folder as the very first (and, for now, only) folder in the cabinet.

Input size → what is feasible. t is any integer from 2 to 105: one node allocation and one diskWrite regardless of t, so Θ(1) — a tree for n = 109 starts exactly like a tree for n = 1.

bTreeCreate runs the same 4 rows no matter how big t is or how many keys will eventually be inserted — it is O(1) disk operations and O(1) CPU time, period. Three players: t = 2 (smallest legal degree), t = 1000 (a realistic disk-page-sized branching factor) and your own t. Each takes exactly 4 frames and 1 diskWrite, which shows CREATE is independent of t and n.

Exactly like flipping through a sorted filing folder: scan the dividers left to right until you either land exactly on the value you want, or find the first divider bigger than it — at which point you know your value (if it exists at all) must be in the sub-folder pocket just behind that point, so you fetch that sub-folder and repeat.

Example 2 — the key is not in the tree. Watch what happens when the scan reaches a leaf and no key matches: there is nowhere left to descend, so the search reports the key is absent.

Example 3 — your own tree and target key. Build any tree and search it:

Running time. The scan inside one node costs O(t) CPU time (up to 2t − 1 keys, scanned linearly), and the search visits O(h) = O(logt n) nodes — one disk access per node beyond the (already-in-memory) root. Total: O(t · logt n) CPU time, O(logt n) disk accesses.

Input size → what is feasible. n = 109, t = 1001 → at most 2 page reads and 2 · 2002 = 4004 key comparisons per lookup; t = 2 → at most 29 reads. A linear scan of all n keys (109 steps ≈ 10 s) is only acceptable for n up to about 104 keys.

What if you replace the linear scan inside each node with binary search? The number of disk accesses (O(h)) is completely unaffected — disk accesses are about how many nodes you visit, not how you search within one already-loaded node — but the CPU time per node drops from O(t) to O(lg t), so overall CPU time becomes O(lg n), independent of t.

bTreeSplitChild(x, i)

A folder has grown completely full (2t − 1 dividers, the maximum allowed). Before it can accept one more, you split it in half: the middle divider gets promoted up into the parent folder (which gains one more divider and one more sub-folder pocket), and the two halves either side of it become two separate, now half-full sub-folders.

Example 1 — splitting a full leaf.

Example 2 — splitting a full internal node (notice the child pointers get redistributed too, not just the keys):

Example 3 — your own node to split:

Input size → what is feasible. t ≤ 1001: a split copies at most 2t − 1 = 2001 keys (Θ(t) CPU) but always costs exactly 3 diskWrites, and a single INSERT splits at most once per level (h ≤ 40).

bTreeSplitChild assumes x is not full but its child y = x.children[i] is full (exactly 2t − 1 keys). It creates a new node z, moves y's largest t − 1 keys (and, if internal, its largest t children) into z, shrinks y down to its smallest t − 1 keys, and inserts the one median key (plus a pointer to z) into x. Cost: Θ(t) CPU time (copying up to t keys/pointers) and exactly 3 disk writes (y, z, x) — O(1) disk operations.

bTreeInsert(tree, k) and bTreeInsertNonFull(x, k)

The key trick: instead of inserting first and dealing with an overflowing folder afterwards (which could cascade all the way back up and force you to revisit folders you already left), you check on the way down whether the folder you're about to step into is already full, and split it proactively — right then, before you ever recurse into it. That single decision is what turns insertion into a single downward pass with zero backtracking.

Example 1 — a mixed 19-key sequence: insert 40, 15, 70, 25, 90, 5, 60, 35, 80, 10, 55, 30, 95, 20, 65, 45, 85, 50, 75 one at a time into an empty B-tree with t = 2 and watch proactive splitting happen live:

One code panel: rows 1-8 are bTreeInsert, rows 9-22 are bTreeInsertNonFull. Captions quote the row (“Line 12”) and the frame highlights the matching row. When a split happens the panel shows one “call” row; the 13 rows of bTreeSplitChild are animated in their own players above. A name label (x, y, z, s, r, c…) above a node tells you which variable of the pseudocode it is, and the counters show diskReads and diskWrites as they happen.

Example 2 — ascending keys, t = 3. Inserting 1, 2, 3, … in increasing order always lands in the rightmost leaf, which fills up and splits over and over; every left half is left only minimally full (t−1 keys). Watch the height grow when the root fills:

Example 3 — your own minimum degree and key sequence:

bTreeInsert first checks only the root: if it's full, a brand-new empty root is created above it, and the (only) way a B-tree ever grows in height happens right here — the old root gets split as the new root's first (and, for a moment, only) child. Either way, bTreeInsertNonFull is then called, and it is a genuinely non-full node every single time it recurses — because any full child it is about to step into gets split first, bTreeInsertNonFull rows 18-19 — before the recursive call on row 22.

Running time. Exactly like search, both procedures do O(h) disk accesses (at most one diskRead per level going down, one diskWrite for the leaf, and 3 diskWrites per split, at most one split per level) and O(t · h) = O(t · logt n) CPU time (each level does O(t) work: a linear scan plus, sometimes, a split). Because bTreeInsertNonFull is tail-recursive (and directly usable in practice), it can be rewritten as a plain loop needing only O(1) pages in memory at any instant — never the whole root-to-leaf path at once.

Input size → what is feasible. n = 106 keys inserted one by one with 2 ≤ t ≤ 16 → about 108 simple steps (fine for ~1 s); one insert is at most h page reads and 3h + 1 page writes (checked above); inserting into a sorted array would cost up to n/2 = 5·105 shifts per key instead.

A common beginner mistake: thinking a node only needs to split after it becomes overfull. This algorithm never lets a node become overfull in the first place — it always checks and splits the child before stepping into it, which is why "insertion is a single downward pass, never backtracking" is true. If you instead split reactively (insert first, then fix up), you're describing a different, correct-but-more-complex algorithm that this lesson deliberately avoids.
bTreeInsert grows the tree's height only by splitting a full root (the one and only place height ever increases). bTreeInsertNonFull proactively splits any full child before recursing into it, guaranteeing the recursion always lands on a non-full node. Both cost O(h) disk accesses and O(t logt n) CPU time.

3. Deleting a key

Deletion is the mirror image of insertion's "split proactively on the way down" trick, but going the other direction: instead of preventing folders from overflowing, you must prevent them from becoming too empty (fewer than t − 1 dividers) on the way down, because this algorithm — like insertion — commits to a single downward pass with no backtracking. So before stepping into any child, you first top it up (borrow a divider from a sibling, or merge two half-empty folders into one) if it's sitting right at the minimum.
The six cases below are written out as one pseudocode listing (38 rows, numbered only so the animation can point at them), and it places diskRead / diskWrite by the same rule as insertion: read a page once when we first look inside it, write a page once after changing it. One small liberty: for cases 2a/2b many presentations say “delete k′ recursively, then replace k”; we replace first and then delete k′, which is equivalent in a single downward pass.

The strengthened invariant is the key idea: whenever bTreeDelete recurses into a node x, it guarantees x has at least t keys — one more than the bare minimum — so that even if we end up removing a key from x, it will never drop below the legal minimum of t − 1. This is arranged in two places: when the key to delete is found in an internal node (cases 2a/2b/2c), and when the key must be found by descending further (case 3, the "cases 3a/3b" preparation step below).

Deleting from a leaf — Case 1

The simplest case: the value you want gone is right there on a divider in the current folder, and the folder has no sub-folders — just remove it and shift the rest of the dividers to close the gap.

Deleting a key found in an internal node — Cases 2a, 2b, 2c

The key to delete sits on a divider that has real sub-folders on both sides. You can't just erase it — something has to take its place, because everything to the left must stay ≤ the new divider and everything to the right must stay ≥ it. The trick: borrow the largest value from the left sub-folder (predecessor, case 2a) or the smallest value from the right sub-folder (successor, case 2b) to fill the gap — either one is guaranteed to sit correctly between its neighbours. Only if neither side can spare a divider without becoming too empty (case 2c) do you weld the two sub-folders together into one, with the key sliding down to join them.

Three fresh, independent trees below — each one isolates exactly ONE of the three cases so you can see it without any other case obscuring it:

Each case is one small function; the dispatcher deleteFrom further down calls them. Cases 2a and 2b first find the predecessor / successor; 2c and 3b share mergeChildren.

Descending through an underfull child — Cases 3a, 3b

The key you're deleting isn't in the current folder at all — you must step into a sub-folder to keep looking. But that sub-folder is sitting right at the legal minimum (one more removal would make it illegal). So before stepping in, you either borrow one divider from a neighbouring sub-folder through the parent (case 3a, if a neighbour can spare one) or you weld the underfull sub-folder together with a neighbour (case 3b, if neither neighbour can spare one) — exactly mirroring cases 2a/2b vs 2c, but applied while merely passing through on the way to a leaf.

Two more fresh, independent trees — one isolating case 3a (borrowing), one isolating case 3b (merging) and, immediately after, a second deletion on that same tiny tree that makes the ROOT itself disappear — the only way a B-tree's height ever decreases:

Example 4 — your own tree, minimum degree, and deletions (delete several keys in a row and watch the case label change live):

A five-step worked example. Starting from a t = 3 tree of 23 keys under the root [100], the player deletes 45, 80, 30, 10 and 70 in turn. Each step triggers a different case — 1, 2a, 2c, 3b (which also makes the root disappear) and 3a — and the scene label names the case the captions must agree with:

All six cases wired together — the whole bTreeDelete as one downward pass:

Running time. Exactly like insertion: O(h) disk accesses (the strengthened invariant needs at most one extra look at a sibling per level) and O(t · h) = O(t · logt n) CPU time.

Input size → what is feasible. n = 106 deletions with 2 ≤ t ≤ 16 → about 108 simple steps; each delete does at most about 4(h + 1) page reads and writes (the random test above measures a worst case below 4(h + 1)), where h ≤ 18 for t = 2.

Cases 2c and 3b look similar (both merge two nodes) but are triggered differently: 2c fires when the key being deleted is itself sitting in the internal node you're currently at, and its two flanking children both refuse to lend a key. 3a/3b fire when the key you want is somewhere further down, and the child you're about to descend into is already at the legal minimum. Mixing these up is the single most common bug when implementing bTreeDelete from scratch.
bTreeDelete maintains the invariant "never recurse into a node with only t−1 keys" via six cases: 1 (leaf, just remove), 2a/2b (steal predecessor/successor from a rich child), 2c (merge, key found internally, neither child rich), 3a (borrow from a sibling while descending), 3b (merge with a sibling while descending). A root that becomes an empty internal node is discarded — the only way a B-tree's height ever shrinks.

4. From pseudocode to working Dart

The pseudocode in this lesson is already 0-indexed, exactly like Dart's List: x.keys[i] and x.children[i] mean the same thing in both. The invariant children.length == keys.length + 1 holds for every internal node, and when you scan with an index i, the child before keys[i] is children[i] and the child after it is children[i + 1]. Here is the complete, generic implementation (full source, exercised by 220 randomized trials plus every worked example above, lives in verify/c18.dart):

class BTreeNode<K extends Comparable> {
  BTreeNode(this.leaf);
  bool leaf;
  List<K> keys = [];
  List<BTreeNode<K>> children = [];
}

class BTree<K extends Comparable> {
  BTree(this.t) {
    if (t < 2) throw ArgumentError('minimum degree t must be >= 2');
  }
  final int t;
  BTreeNode<K>? root;
  int diskReads = 0;
  int diskWrites = 0;

  int get maxKeys => 2 * t - 1;
  int get minKeys => t - 1;

  // bTreeSearch(x, k)
  bool contains(K k) => root == null ? false : _search(root!, k, true);
  bool _search(BTreeNode<K> x, K k, bool isRoot) {
    if (!isRoot) diskReads++;
    var i = 0;
    while (i < x.keys.length && k.compareTo(x.keys[i]) > 0) {
      i++;
    }
    if (i < x.keys.length && k.compareTo(x.keys[i]) == 0) return true;
    if (x.leaf) return false;
    return _search(x.children[i], k, false);
  }

  // bTreeCreate + bTreeInsert
  void insert(K k) {
    if (root == null) {
      root = BTreeNode<K>(true); // bTreeCreate
      diskWrites++; // its diskWrite
    }
    final r = root!;
    if (r.keys.length == maxKeys) {
      final s = BTreeNode<K>(false);
      s.children.add(r);
      root = s;
      _splitChild(s, 0);
      _insertNonFull(s, k);
    } else {
      _insertNonFull(r, k);
    }
  }

  // bTreeSplitChild(x, i) — 0-indexed i, splits the FULL child x.children[i]
  void _splitChild(BTreeNode<K> x, int i) {
    final y = x.children[i];
    final z = BTreeNode<K>(y.leaf);
    z.keys = y.keys.sublist(t);
    if (!y.leaf) z.children = y.children.sublist(t);
    final median = y.keys[t - 1];
    y.keys = y.keys.sublist(0, t - 1);
    if (!y.leaf) y.children = y.children.sublist(0, t);
    x.children.insert(i + 1, z);
    x.keys.insert(i, median);
    diskWrites += 3; // y, z, x
  }

  // bTreeInsertNonFull(x, k)
  void _insertNonFull(BTreeNode<K> x, K k) {
    var i = x.keys.length - 1;
    if (x.leaf) {
      x.keys.add(k); // grow by one slot, then shift into place
      while (i >= 0 && k.compareTo(x.keys[i]) < 0) {
        x.keys[i + 1] = x.keys[i];
        i--;
      }
      x.keys[i + 1] = k;
      diskWrites++;
    } else {
      while (i >= 0 && k.compareTo(x.keys[i]) < 0) {
        i--;
      }
      i++;
      diskReads++;
      if (x.children[i].keys.length == maxKeys) {
        _splitChild(x, i);
        if (k.compareTo(x.keys[i]) > 0) i++;
      }
      _insertNonFull(x.children[i], k);
    }
  }
}

The full verify/c18.dart file also includes delete(k) (all six cases, plus the root-shrink check), inorder(), and checkInvariants() — the exact same logic this page's JavaScript animation engine runs, just translated line-for-line into 0-indexed Dart.

The most common off-by-one when writing a B-tree: after scanning to key index i, the child that lies before that key is children[i] and the child after it is children[i + 1] — the “after” side is one further along, not the same index. Splitting child i likewise puts the new sibling at children[i + 1] and the promoted median at keys[i].

Quiz

Interview questions

Cheat sheet

OperationDisk accessesCPU timeNotes
bTreeCreate(t)O(1)O(1)Independent of t and n — one empty leaf root
bTreeSearch(x, k)O(h) = O(logt n)O(t·h) = O(t·logt n); O(lg n) with binary search inside a node(x.keys.length + 1)-way branch at each node
bTreeSplitChild(x, i)O(1) — exactly 3 writesΘ(t)Median key moves up; called proactively, never after the fact
bTreeInsert(tree, k)O(h)O(t·h) = O(t·logt n)Root split is the ONLY way height increases
bTreeInsertNonFull(x, k)O(h)O(t·h)Tail-recursive → rewritable as an O(1)-memory loop
bTreeDelete(x, k)O(h)O(t·h) = O(t·logt n)6 cases (1, 2a, 2b, 2c, 3a, 3b); root shrink is the ONLY way height decreases
Height boundh ≤ logt((n+1)/2) — vs. Θ(lg n) for a red-black tree: a factor of lg t fewer levels
t = 2 special caseExactly a 2-3-4 tree (2, 3, or 4 children per internal node); corresponds to a red-black tree (each black node absorbs its red children)
SpaceΘ(n) keys stored, Θ(n/t) nodes; every node uses Θ(t) words regardless of how full it is