Building DBDB from Scratch — Part 13: Adding a B-Tree


Binary trees have a fundamental problem: one child per key, which means one level per factor of two. Ten levels gives you a thousand keys. Twenty levels gives you a million. Every level is a disk read.

Production databases figured this out decades ago. SQLite doesn’t use a binary tree. PostgreSQL doesn’t use one. Neither does MySQL, or RocksDB, or any other storage engine you would trust with real data. They all use B-trees — or close variants of them. The reason is simple: a node that holds ten keys means ten times fewer levels for the same number of keys. Fewer levels means fewer disk reads. Fewer disk reads means faster queries.

DBDB had an AVL tree. It was balanced, it was correct, and compared to the unbalanced BST it replaced, it was meaningfully better. But it was still binary. The question was whether a B-tree would actually improve things — or just be more complex for complexity’s sake.

This is what happened when we found out.


About this series

Build DBDB from scratch is a walkthrough of rebuilding DBDB, the Dog Bed Database from 500 Lines or Less. Each post focuses on one layer of the implementation.

Part Core idea
0 Project setup: pyproject.toml, smoke tests, pytest + BDD, Makefile
1 Append-only storage: superblock, write/read, root commit, flush/fsync, locking
2 ValueRef and lazy loading: get/store, BytesValueRef, UTF-8 on disk
3 Immutable tree and BinaryNodeRef: copy-on-write, node serialization, lazy children
4 Logical layer: LogicalBase + BinaryTree: lifecycle vs. algorithms
Interlude End-to-end flow: one key through all layers
6 Locking across layers: the two-writer race
7 Two lines that hold everything: commit, get, set, pop
8 The thinnest layer: the DBDB facade
9 The last translation: the CLI tool
10 What immutability costs: compaction
Retrospective What a database actually is
12 Replacing the BST with an AVL tree
13 (this post) Adding a B-Tree
14 Atomic, thread-safe updates

Why Binary Trees Aren’t Enough

AVL and BST are both binary trees. Each node holds exactly one key and has at most two children. The shape is elegant, and the algorithms are clean.

But binary means logarithm base two. A balanced binary tree with a million keys has height log₂(1,000,000) ≈ 20. That’s 20 disk reads to find a key in a million-key database. Each read is a seek, a wait, a transfer.

A B-tree generalizes this. Given a minimum degree t, each internal node holds between t-1 and 2t-1 keys, and has between t and 2t children. The height is now log_t(n). With t=50, a million-key database has height at most

  1. Four disk reads instead of twenty.

The tradeoff is complexity. Nodes aren’t single keys anymore — they’re arrays. Insertion can’t just walk down one path; it might split a full node and promote a key to the parent. Deletion has three distinct cases depending on whether you can borrow from a sibling or need to merge two nodes together. The invariants are harder to maintain and harder to verify.

For this implementation, the minimum degree is t=3. That gives nodes 2 to 5 keys, and 3 to 6 children. Small enough that all the split and merge cases fire with just a handful of keys, large enough to be non-trivial. The goal is to understand the mechanics, not to build a storage engine.


A Node That Holds More Than One Key

The first thing to build was the node. BinaryNode was simple: one key, one value reference, two child pointers, and a length. BTreeNode is different in kind, not just degree.

@dataclass
class BTreeNode:
    keys:       list[str]
    value_refs: list[ValueRef]
    child_refs: list[BTreeNodeRef]
    length:     int
    is_leaf:    bool

The constraint that makes this a B-tree rather than just a node with arrays: if the node is a leaf, child_refs must be empty. If it’s internal, it must have exactly len(keys) + 1 children. A node with two keys has three children: one to the left of the first key, one between the two keys, one to the right of the second. The __post_init__ check enforces this at construction time, so a malformed node cannot be created.

length counts total keys in the subtree rooted here — not just the keys in this node. A node with 2 keys and 3 children, where each child is a leaf with 2 keys, has length 2 + 2 + 2 + 2 = 8. LogicalBase.__len__ relies on this to return the total size of the tree without a traversal.

Serialization follows the same pattern as BinaryNodeRef, but the msgpack dict carries arrays instead of scalars:

{
    "keys":     ["key1", "key2"],
    "values":   [addr1, addr2],
    "children": [addr1, addr2, addr3],
    "length":   8,
    "is_leaf":  False,
}

prepare_to_store has to flush all value refs and all child refs to disk before encoding the node itself. Nested refs must have addresses before the parent can encode them. The same invariant that applied to BinaryNodeRef applies here, just with more refs to flush.


Search: Walking Down, Not Just Left or Right

B-tree search is recognizable from the binary case, but the branching is wider.

At each node, find where the key fits among the node’s keys. If the key matches one exactly, return its value. If the key is less than keys[i] for some i, descend into child_refs[i]. If the key is greater than all keys in the node, descend into the last child. If you reach a leaf without finding the key, it doesn’t exist.

def _get(self, node, key):
    for i, k in enumerate(node.keys):
        if k == key:
            return self._follow(node.value_refs[i])
        elif k > key:
            if node.is_leaf:
                raise KeyError(key)
            return self._get(self._follow(node.child_refs[i]), key)
    if node.is_leaf:
        raise KeyError(key)
    return self._get(self._follow(node.child_refs[-1]), key)

A concrete trace helps. Given this tree with t=3:

              [G     M]
            /    |    \
       [B E]  [H K]   [N R]

Search for "K": at [G M], K > G and K < M → middle child. At [H K], K > H, K == K → found in one additional read.

Search for "F": at [G M], F < G → left child. At [B E], F > B, F > E, no match left and node is a leaf → KeyError.

The number of nodes visited is bounded by the tree height. At t=3 and 10,000 keys, that ceiling is 9 — regardless of which key you look up.

In-order traversal — needed for iteration — follows the same structure. For an internal node: recurse into child 0, yield key 0, recurse into child 1, yield key 1, …, yield key k-1, recurse into the last child. For a leaf: yield each key in order. The result is always sorted, regardless of what order the keys were inserted.

Applied to the same tree, iteration yields: B, E, G, H, K, M, N, R.


Insertion: Splitting on the Way Down

Insertion is where B-trees diverge from everything that came before.

Binary trees insert by walking down until they find an empty slot. The structure never changes until you hit the bottom. B-trees can’t do this: if you insert into a full node, you need to split it and promote a key to the parent. But if the parent is also full, you need to split that too. In the worst case, splits cascade from leaf to root.

There are two ways to handle this. The first — bottom-up splitting — descends to the leaf, then handles splits on the way back up. The information about whether a split happened propagates upward through the return values. It works, but every level of recursion has to be prepared to handle a split from below.

The second — top-down splitting — checks each node before descending into it. If the child you’re about to enter is full, split it first, then descend. This guarantees that when you arrive at any node, it already has room. No cascading needed, no information to pass upward. The implementation is simpler to reason about.

DBDB uses top-down splitting.

_split_child(parent, child_index) splits the full child at that index into two nodes: left gets the first t-1 keys, right gets the last t-1 keys, and the median key at index t-1 gets promoted to the parent. The parent gains one key and one child pointer. Everything produces new nodes — nothing is mutated in place.

With t=3, a full node has 5 keys. The median index is t-1 = 2 (zero-indexed), so the median key is the third one. Splitting [A B C D E] yields two nodes of t-1 = 2 keys each, with C promoted to the parent:

    before: parent [...  P  ...]
                         |
                   [A  B  C  D  E]   ← full (5 keys)

    after:  parent [...  C  P  ...]   ← C (median) promoted
                        / \
                    [A B]  [D E]      ← 2 keys each (t-1)

There’s one extra case: when the root itself is full. You can’t promote to the root’s parent because the root has no parent. The solution is to create a new root that initially has no keys and just one child (the old root), then split that child. The tree grows upward.

    before:    [A  B  C  D  E]   ← root, full

    step 1:         []            ← new empty root
                    |
               [A B C D E]

    step 2:        [C]            ← C promoted, tree grows up
                  /   \
              [A B]   [D E]
def _insert(self, node, key, value_ref):
    if node is None:
        return BTreeNodeRef(referent=BTreeNode([key], [value_ref], [], 1, True))

    if self._is_full(node):
        new_root = BTreeNode([], [], [BTreeNodeRef(referent=node)], node.length, False)
        new_root = self._split_child(new_root, 0)
        return self._insert_non_full(new_root, key, value_ref)
    else:
        return self._insert_non_full(node, key, value_ref)

_insert_non_full handles the descent. Before entering any child, it checks whether that child is full. If so, it splits it first — guaranteeing there is always room when the recursion actually arrives:

def _insert_non_full(self, node, key, value_ref):
    if node.is_leaf:
        # find position and insert
        i = 0
        while i < len(node.keys) and node.keys[i] < key:
            i += 1
        # ... insert at position i
    else:
        i = 0
        while i < len(node.keys) and node.keys[i] < key:
            i += 1
        child = node.child_refs[i].get(self._storage)
        if self._is_full(child):
            node = self._split_child(node, i)   # split before descending
            if node.keys[i] < key:
                i += 1
        child = node.child_refs[i].get(self._storage)
        new_child_ref = self._insert_non_full(child, key, value_ref)
        # ... rebuild node with updated child

After N insertions in any order — sorted, reverse, random — all leaves sit at the same depth. This is the B-tree’s core guarantee. It doesn’t maintain balance by rotating; it maintains balance by ensuring every split happens symmetrically, always at the median.


Deletion: Three Cases and One Invariant

Every non-root B-tree node must have at least t-1 keys. Insertion never violates this because we only insert into nodes that aren’t full. Deletion can violate it if you remove from a node that’s already at the minimum.

The fix is the same discipline as insertion: handle the problem before descending. Before entering any child, ensure it has at least t keys — one above the minimum, so that if we delete from it, it won’t underflow.

If the child has exactly t-1 keys, there are three options.

Borrow from left sibling — the left sibling has ≥ t keys. Rotate one key clockwise: the separator between the two siblings descends into the child, the sibling’s rightmost key ascends to take its place in the parent.

    before:      [E     M]          want to descend into [H K],
                /   |    \          but [H K] is at minimum (t-1=2 keys)
          [B D F]  [H K]  [N R]    ← left sibling has 3 ≥ t=3 keys → can lend

    after:       [F     M]
                /   |    \
          [B D]  [E H K]  [N R]    ← F up, E down; [H K] now has 3 keys

Borrow from right sibling — mirrored: the separator descends into the child from the right, the sibling’s leftmost key ascends.

    before:      [E     M]          want to descend into [H K],
                /   |    \          left sibling [B C] also at minimum
           [B C]  [H K]  [N R S]   ← right sibling has 3 ≥ t=3 keys → can lend

    after:       [E     N]
                /   |    \
           [B C]  [H K M]  [R S]   ← M down, N up; [H K] now has 3 keys

Merge — both siblings are at the minimum. Pull the separator key down from the parent and merge the child with one sibling into a single node.

    before:      [E     M]          want to descend into [H K],
                /   |    \          both siblings at minimum t-1=2 keys
          [B D]  [H K]  [N R]      ← neither sibling can lend → merge

    after:         [M]              E pulled down, [B D] and [H K] merged
                  /   \
          [B D E H K]  [N R]       ← parent loses one key and one child

The _ensure_min_keys method encodes this logic before every descent:

def _ensure_min_keys(self, parent, child_index):
    child = parent.child_refs[child_index].get(self._storage)
    if len(child.keys) >= self.T:
        return parent, child_index          # already has room

    if child_index > 0:
        left = parent.child_refs[child_index - 1].get(self._storage)
        if len(left.keys) >= self.T:
            return self._borrow_from_left(parent, child_index), child_index

    if child_index < len(parent.child_refs) - 1:
        right = parent.child_refs[child_index + 1].get(self._storage)
        if len(right.keys) >= self.T:
            return self._borrow_from_right(parent, child_index), child_index

    if child_index > 0:
        return self._merge_with_left(parent, child_index), child_index - 1
    else:
        return self._merge_with_right(parent, child_index), child_index

When a key lives in an internal node rather than a leaf, you can’t just remove it — it’s also serving as the separator between two subtrees. The solution is to replace it with its in-order predecessor (the rightmost key in the left child’s subtree) or successor (the leftmost key in the right child’s subtree), then delete that key from the child it came from.

    delete "E" from internal node:

         [E     M]
        /   |    \
   [B D]  [H K]  [N R]

    step 1: find predecessor of E = D (rightmost key of left child)
    step 2: replace E with D in parent
    step 3: delete D from left child (a leaf — straightforward)

         [D     M]
        /   |    \
     [B]  [H K]  [N R]

If both children of the separator are at the minimum, borrowing isn’t possible. Merge the two children with the separator key pulled down into the merged node, then delete the key from that merged node:

    delete "E", but both children at minimum (t-1=2 keys):

         [E     M]
        /   |    \
     [B C]  [H K]  [N R]

    step 1: merge [B C], E, [H K] into one node → [B C E H K]
    step 2: delete E from the merged leaf

         [M]
        /   \
  [B C H K]  [N R]

All of this produces trees where every non-root node has between t-1 and 2t-1 keys after any sequence of operations. The guarantee holds by induction: we ensure it before every descent.


Wiring Into DBDB

The integration was mechanical. Byte 8 of the superblock already stored 0 for BST and 1 for AVL; 2 went to B-tree.

_init_tree gained a new branch:

def _init_tree(self) -> None:
    if self._tree_type_flag == 2:
        from dbdb.btree import BTree
        self._tree = BTree(self._storage)
    elif self._tree_type_flag == 1:
        from dbdb.avl_tree import AVLTree
        self._tree = AVLTree(self._storage)
    else:
        from dbdb.binary_tree import BinaryTree
        self._tree = BinaryTree(self._storage)

connect() learned one new keyword:

db = dbdb.connect("mydb.db", tree_type="btree")

There was one bug hiding in compact(). The method needed to create a new database with the same tree type as the original:

tree_type_str = "avl" if self._tree_type_flag == 1 else "bst"

That line predated B-tree support. A flag of 2 silently fell through to "bst", so compacting a B-tree database would produce a BST database. Opening it again as a B-tree would fail trying to read BST-formatted nodes. The fix was trivial — add the missing case — but the failure was quiet until the benchmark hit compaction.


What the Numbers Showed

The benchmark ran 10,000 random key insertions followed by random reads on a fresh database, then a separate compaction test with 1,000 keys overwritten ten times each. All figures are averages over five runs.

General performance (10,000 random keys):

Operation BST AVL B-tree
Sequential writes 5,230 ops/s 4,675 ops/s 7,473 ops/s
Random reads 21,276 ops/s 25,987 ops/s 32,543 ops/s

The B-tree won both. Writes were 60% faster than AVL; reads were 25% faster. Both were the opposite of what the plan predicted.

The write result makes sense once you think about what’s actually happening on disk per insert. AVL creates a new node at every level of the tree on the way back up — up to two per insert in double-rotation cases. B-tree splits are rare: with t=3, a leaf holds up to 5 keys before splitting, so most insertions just extend an existing leaf node and write nothing more. The total disk footprint per insert is lower.

The read result is the tree height story in actual numbers. With t=3 and 10,000 keys, a B-tree reaches height at most log₃(10,000) ≈ 8.4. An AVL tree stays at log₂(10,000) ≈ 13.3. Each lookup touches fewer nodes. In DBDB, each node visit means a disk read; scanning up to 5 keys within one node is cheaper than paying for an additional read at the next level.

Compaction impact (1,000 keys, 10 overwrites each):

Metric BST before BST after AVL before AVL after B-tree before B-tree after
File size (bytes) 1,822,926 184,735 1,822,830 184,729 1,600,547 163,277
Random reads (ops/s) 26,535 27,761 33,662 35,300 38,381 41,043
Compaction time (s) 0.028 0.030 0.019

File size was the clearest structural difference. Before compaction, the bloated B-tree file sat at 1.6 MB versus 1.8 MB for BST and AVL — about 12% smaller, because B-tree nodes pack multiple keys per block. After compaction, 163 KB versus 184 KB — about 11% smaller, for the same reason.

Compaction time was the surprise: B-tree was the fastest at 0.019 s, beating both BST (0.028 s) and AVL (0.030 s). The reason is the same as the write story — fewer nodes written during the rebuild. Re-inserting 1,000 keys into a fresh B-tree produces far fewer nodes than inserting the same 1,000 keys into a BST or AVL tree, because each B-tree leaf absorbs up to 5 keys before a split. Less to write means faster compaction.

After compaction, all three trees improved their read throughput by 5–7%. A compacted database has no stale data — the file is 90% smaller, the live nodes are tightly packed, and the OS has less to cache. The B-tree went from 38,381 to 41,043 ops/s, the largest absolute gain, which is consistent with it starting from the highest baseline.


The Summary

Three tree types now live in DBDB. All 216 tests pass across all of them.

Tree Write (ops/s) Read (ops/s) File size (1K keys, 10×) Compaction (s)
BST 5,230 21,276 1,822,926 bytes 0.028
AVL 4,675 25,987 1,822,830 bytes 0.030
B-tree 7,473 32,543 1,600,547 bytes 0.019

B-tree wins on every dimension. It’s faster to write, faster to read, produces smaller files, and compacts faster. The complexity of multi-key nodes and proactive splitting pays for itself at 10,000 keys, and the advantage only grows as the database does — log base 3 scales much better than log base 2.

The BST is still there, still reachable. Old databases still work. The AVL tree is still a reasonable middle ground. And now there’s a B-tree for anyone who wants to understand why every serious storage engine ultimately converges on this shape.

The implementation was the hard part. The benchmark was just confirmation.