Building DBDB from Scratch — Part 12: Replacing BST with AVL
The retrospective had listed it as a known limitation:
The tree doesn’t rebalance. Compaction inserts keys in sorted order, producing a skewed BST with O(n) lookup.
Known limitation is a polite way of saying “we know this is broken and haven’t fixed it yet.” The benchmark was supposed to put a number on how broken. It did — just not in the way expected.
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 (this post) | Replacing the BST with an AVL tree |
| 13 | Adding a B-Tree |
| 14 | Atomic, thread-safe updates |
The Benchmark Runs. Things Break.
The first run didn’t get far.
compact() was reading all key-value pairs from the old file to copy into
a new one. It got them through self.items(), which at the time looked like
this:
def items(self):
self._assert_not_closed()
for key in self:
yield (key, self[key])
for key in self walks the tree in-order — one full traversal to get every
key. Then self[key] for each key climbs back down the tree from the root
to find the value. For a database with 1,000 keys, that’s 1,001 tree walks.
Every disk read, every pointer follow, done a thousand times over.
This was the N+1 problem hiding in plain sight. No one had noticed because
items() was only ever called from compact(), and compact() had never
been called on a real database until now.
The fix was straightforward: add a traversal to BinaryTree that yields
both key and value in one pass, without ever going back to the root.
def _iter_items(self, node):
if node:
yield from self._iter_items(self._follow(node.left_ref))
yield (node.key, self._follow(node.value_ref))
yield from self._iter_items(self._follow(node.right_ref))
def items(self):
root = self._follow(self._tree_ref)
yield from self._iter_items(root)
One traversal. Every node visited once, key and value resolved together.
DBDB.items() was updated to just delegate down:
def items(self):
self._assert_not_closed()
self._reopen_if_replaced()
return self._tree.items()
With that fixed, the benchmark ran again.
And crashed with a different error.
RecursionError
RecursionError: maximum recursion depth exceeded
items() yields keys in sorted order — that’s what in-order traversal
produces. compact() was taking those sorted keys and inserting them one
by one into the new BST:
for key, value in self.items():
new_db[key] = value
Inserting sorted keys into a BST is the worst possible input. Every new
key is larger than everything already in the tree, so every insert goes to
the rightmost child. After 1,000 inserts, the “tree” is a chain 1,000 nodes
long. _insert is recursive. Inserting the 1,001st key requires a call
stack 1,000 frames deep. Python’s default limit is 1,000.
The fix was to break the sorted order before inserting: load all items into memory, shuffle them, then insert in random order.
items = list(self.items())
random.shuffle(items)
for key, value in items:
new_db[key] = value
new_db.commit()
Random insertion gives a BST an expected height of about 2 ln N — around 14 levels for 1,000 keys. That’s well within the recursion limit, and the tree ends up reasonably balanced.
The benchmark finally ran to completion.
The Shuffle Isn’t Enough
The shuffle fixed the crash. It didn’t fix the tree.
“Reasonably balanced” is not the same as “balanced.” A randomly ordered BST has an expected height of 2 ln N, but that’s an average — a particular insertion order might still produce a lopsided tree. And loading the entire database into memory to shuffle it is a trade-off: it works for a prototype, but a database too large to fit in RAM has nowhere to go with this approach.
The right answer was already obvious: use a self-balancing tree. One that keeps itself balanced automatically, regardless of insertion order, without needing the shuffle workaround at all.
The BST had done its job as a starting point. It was time to replace it.
Adding Height to Every Node
An AVL tree keeps itself balanced by enforcing one rule: at every node, the height of the left subtree and the height of the right subtree can differ by at most one. To enforce that rule, every node has to know its own height.
That meant adding a field to BinaryNode:
# Before
@dataclass
class BinaryNode:
left_ref: BinaryNodeRef
key: str
value_ref: ValueRef
right_ref: BinaryNodeRef
length: int
# After
@dataclass
class BinaryNode:
left_ref: BinaryNodeRef
key: str
value_ref: ValueRef
right_ref: BinaryNodeRef
length: int
height: int
One field. The ripple effects were wider than expected.
Every place that constructed a BinaryNode now needed a height. New leaf
nodes get height 0. And from_node — the copy-with-overrides constructor
used all through the insert/delete path — had to learn to accept an explicit
height, because rotations recalculate heights and need to write the new value
back in:
@classmethod
def from_node(cls, node: BinaryNode, **kwargs: Any) -> BinaryNode:
length = node.length
if "left_ref" in kwargs:
length += kwargs["left_ref"].length - node.left_ref.length
if "right_ref" in kwargs:
length += kwargs["right_ref"].length - node.right_ref.length
return cls(
left_ref=kwargs.get("left_ref", node.left_ref),
key=kwargs.get("key", node.key),
value_ref=kwargs.get("value_ref", node.value_ref),
right_ref=kwargs.get("right_ref", node.right_ref),
length=length,
height=kwargs.get("height", node.height), # new
)
Without that last line, from_node copies the old height into the new node
even after its subtree has structurally changed. The rotation runs, the
pointers move, and the height stays stale. That bug survived several
test iterations before the root cause became clear.
The serialization layer needed updating too. BinaryNodeRef packs nodes
into msgpack dicts. height had to go in both directions:
@staticmethod
def referent_to_bytes(referent: BinaryNode) -> bytes:
return msgpack.packb({
"left": referent.left_ref.address,
"key": referent.key,
"value": referent.value_ref.address,
"right": referent.right_ref.address,
"length": referent.length,
"height": referent.height, # new
})
@staticmethod
def bytes_to_referent(data: bytes) -> BinaryNode:
d = msgpack.unpackb(data, raw=False)
return BinaryNode(
BinaryNodeRef(address=d["left"]),
d["key"],
ValueRef(address=d["value"]),
BinaryNodeRef(address=d["right"]),
d["length"],
d["height"], # new
)
Adding height to the serialization format is a breaking change. Any database
written before this point can’t be read by the new code — the old msgpack dicts
don’t have a "height" key. That’s the reason for the tree type flag introduced
later: it marks which format a file was written in.
Two Helper Functions and a New File
With the node foundation ready, avl_tree.py started from scratch. The first
two things it needed were _get_height and _get_balance_factor:
class AVLTree(LogicalBase):
node_ref_class = BinaryNodeRef
value_ref_class = ValueRef
def _get_height(self, node: Optional[BinaryNode]) -> int:
if node is None:
return -1 # null pointer → height -1 by convention
return node.height
def _get_balance_factor(self, node: Optional[BinaryNode]) -> int:
if node is None:
return 0
return (
self._get_height(self._follow(node.left_ref))
- self._get_height(self._follow(node.right_ref))
)
height(None) = -1 is the convention that makes the arithmetic clean.
A leaf node has two null children, so height = max(-1, -1) + 1 = 0.
The balance factor at every non-null node is just left height - right height.
If it goes above 1 or below -1, the tree needs a rotation.
Rotations Without Mutation
AVL rotations are usually explained with diagrams of pointers being redirected. Right-rotate Y: make Y’s left child X the new root, move X’s right subtree to become Y’s new left child, update heights. Three pointer rewrites and done.
DBDB’s nodes are immutable. Nothing on disk ever gets rewritten. So instead of redirecting pointers, you create new nodes that point differently.
A right rotation produces two new nodes: a new version of Y (now demoted, with a different left child) and a new version of X (now the root, with new-Y as its right child). The old X and Y sit untouched on disk. Eventually compaction cleans them up.
The ordering matters: you have to compute new-Y’s height before computing new-X’s height, because new-X depends on new-Y. Getting this backwards produces a stale height that propagates silently through subsequent operations.
def _right_rotate(self, old_root: BinaryNode) -> BinaryNodeRef:
new_root = self._follow(old_root.left_ref)
moved_subtree_ref = new_root.right_ref
# First: rebuild old_root with its new left child, then recalculate its height
old_root_updated = BinaryNode.from_node(old_root, left_ref=moved_subtree_ref)
old_root_updated = BinaryNode.from_node(
old_root_updated,
height=max(
self._get_height(self._follow(old_root_updated.left_ref)),
self._get_height(self._follow(old_root_updated.right_ref)),
) + 1,
)
# Then: rebuild new_root with old_root_updated as its right child
new_root_updated = BinaryNode.from_node(
new_root, right_ref=self.node_ref_class(referent=old_root_updated)
)
new_root_updated = BinaryNode.from_node(
new_root_updated,
height=max(
self._get_height(self._follow(new_root_updated.left_ref)),
self._get_height(self._follow(new_root_updated.right_ref)),
) + 1,
)
return self.node_ref_class(referent=new_root_updated)
_left_rotate is the mirror: the right child becomes the new root, its left
subtree moves to become the old root’s right child.
Balancing After Every Insert
The _insert logic in AVLTree starts the same as in BinaryTree — walk
down, place the key in the right spot. The difference is what happens on the
way back up.
After each recursive call returns, the current node’s height gets updated, the balance factor gets checked, and if the tree is out of balance, one of four rotation cases fires:
def _insert(self, node, key, value_ref):
# Walk down — same as BST
if node is None:
return self.node_ref_class(referent=BinaryNode(
self.node_ref_class(), key, value_ref,
self.node_ref_class(), 1, 0,
))
if key < node.key:
node_updated = BinaryNode.from_node(
node, left_ref=self._insert(self._follow(node.left_ref), key, value_ref)
)
elif node.key < key:
node_updated = BinaryNode.from_node(
node, right_ref=self._insert(self._follow(node.right_ref), key, value_ref)
)
else:
node_updated = BinaryNode.from_node(node, value_ref=value_ref)
# On the way back up — update height, then rebalance if needed
node_updated = BinaryNode.from_node(
node_updated,
height=max(
self._get_height(self._follow(node_updated.left_ref)),
self._get_height(self._follow(node_updated.right_ref)),
) + 1,
)
balance = self._get_balance_factor(node_updated)
if balance > 1 and key < self._follow(node_updated.left_ref).key:
return self._right_rotate(node_updated) # Left-Left
if balance < -1 and key > self._follow(node_updated.right_ref).key:
return self._left_rotate(node_updated) # Right-Right
if balance > 1 and key > self._follow(node_updated.left_ref).key:
node_updated = BinaryNode.from_node( # Left-Right
node_updated,
left_ref=self._left_rotate(self._follow(node_updated.left_ref))
)
return self._right_rotate(node_updated)
if balance < -1 and key < self._follow(node_updated.right_ref).key:
node_updated = BinaryNode.from_node( # Right-Left
node_updated,
right_ref=self._right_rotate(self._follow(node_updated.right_ref))
)
return self._left_rotate(node_updated)
return self.node_ref_class(referent=node_updated)
The four cases cover every way a tree can go out of balance after an insert. Left-Left and Right-Right need one rotation each. Left-Right and Right-Left need two. Deletion uses the same four cases, but triggered by the child’s balance factor rather than the inserted key — because there’s no key to compare against when removing.
Keeping Old Databases Working
AVLTree was ready. The next question was: what happens to databases that
were written with the old BST code?
Old databases have nodes serialized without a height field. Reading them
with the new bytes_to_referent would crash on d["height"]. And even if
that were handled, an old BST database can’t just be silently read as an AVL
tree — the height values would be wrong or missing.
The cleanest solution was to record which format a database was written in. The superblock was the natural place: it’s 4096 bytes, only the first 8 were in use. Byte 8 — right after the root address — became a tree type flag.
def get_tree_type(self) -> int:
self._seek_superblock()
self._f.seek(self.INTEGER_LENGTH, os.SEEK_CUR) # skip root address
data = self._f.read(1)
if not data:
return 0
return struct.unpack("!B", data)[0]
def set_tree_type(self, tree_type: int) -> None:
self.lock()
self._seek_superblock()
self._f.seek(self.INTEGER_LENGTH, os.SEEK_CUR)
self._f.write(struct.pack("!B", tree_type))
self._f.flush()
self._fsync_if_possible()
self.unlock()
0 = BST. 1 = AVL. Old databases have a zero there because the superblock
was initialized to all zeros when first created. That zero reads as BST, which
is exactly right — no migration needed.
DBDB.__init__ reads the flag on open and uses it to pick the right tree:
def __init__(self, f: IO, tree_type: str = "bst"):
self._storage = Storage(f)
root_addr = self._storage.get_root_address()
if root_addr == 0:
# New file — write the caller's choice into the superblock
type_flag = 1 if tree_type == "avl" else 0
self._storage.set_tree_type(type_flag)
else:
# Existing file — ignore the caller, use what the file says
type_flag = self._storage.get_tree_type()
self._tree_type_flag = type_flag
self._init_tree()
def _init_tree(self) -> None:
if 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)
The rule: a new file follows the caller’s request. An existing file ignores it. The file always wins. Compaction carries the flag forward when it creates the replacement file, so the tree type survives a compact.
The connect() call got one optional argument:
db = dbdb.connect("mydb.db") # BST (default)
db = dbdb.connect("mydb.db", tree_type="avl") # AVL, new file
db = dbdb.connect("existing.db", tree_type="avl") # AVL ignored — file says BST
What the Numbers Looked Like
The benchmark ran both trees side by side: 10,000 random keys for general performance, and 1,000 keys overwritten ten times each to test compaction.
General performance:
| Operation | BST | AVL |
|---|---|---|
| Sequential writes | 5,028 ops/s | 4,794 ops/s |
| Random reads | 20,549 ops/s | 21,875 ops/s |
Writes dropped about 5%. That’s the cost of checking the balance factor and potentially running a rotation on every insert. Reads improved about 6.5% — smaller than expected. With 10,000 randomly inserted keys, the BST ends up reasonably balanced on its own (expected height around 2 ln 10,000 ≈ 18 levels), while the AVL tree stays closer to log₂ 10,000 ≈ 13 levels. The gap exists, but random insertion is already a fairly kind workload for a BST.
Compaction impact:
| Metric | BST before | BST after | AVL before | AVL after |
|---|---|---|---|---|
| File size (bytes) | 1,822,832 | 184,740 | 1,822,828 | 184,730 |
| Random reads (ops/s) | 25,514 | 26,522 | 33,521 | 34,443 |
| Compaction time (s) | — | 0.023 | — | 0.030 |
Here the gap is harder to ignore. Even before compaction, the AVL tree is reading at 33,521 ops/s while the BST sits at 25,514 — about 31% faster. With 1,000 keys, the BST’s expected height is around 14 levels; the AVL tree stays around 10. Fewer levels, fewer disk reads per lookup.
File sizes are nearly identical — the height field adds a small constant
per node, but most of the file is key and value bytes. Both trees shrank
about 90% after compaction. AVL compaction took 30ms vs the BST’s 23ms,
because every re-insert during the rebuild triggers balance checks. At
that scale the difference doesn’t matter.
What Actually Changed
Four files. avl_tree.py was new. binary_tree.py got height on
BinaryNode and the from_node update. physical.py got get_tree_type
and set_tree_type. interface.py got _init_tree and the flag-reading
logic in __init__.
The CLI didn’t change. The compaction logic didn’t change in structure.
The storage layer’s read/write/lock API didn’t change. AVLTree extended
LogicalBase and slotted into the same position BinaryTree had occupied
— same interface, same contract, different internals.
The 165 tests that existed before passed without modification. Only the
handful of assertions that specifically checked isinstance(..., BinaryTree)
needed updating to isinstance(..., AVLTree).
The BST is still there, still reachable via tree_type="bst". Old databases
keep working exactly as before. The AVL tree is now the default for new ones
— and the shuffle in compact() that was keeping things from crashing is
still there too, as a belt-and-suspenders measure even for a balanced tree.
The two bugs from the first benchmark run turned out to be symptoms of the same underlying problem: a tree with no opinion about its own shape. The AVL tree has an opinion, and it enforces it.