Learn Zig Series (#108) - B-Trees
Learn Zig Series (#108) - B-Trees
What will I learn?
- Why a B-tree is the deterministic answer to the exact question skip lists answered with coin flips -- "how do I keep an ordered collection fast while it mutates constantly" -- and why "deterministic" here buys you a guaranteed height bound in stead of an expected one;
- Why the trick is to stop thinking one-key-per-node and instead pack many keys into a single fat node, sized so that touching one node buys you a whole fistful of comparisons -- the design that maps onto disk blocks and cache lines;
- How to build a working B-tree from scratch in Zig -- fixed-capacity key and child arrays sized by a
comptimeminimum degree (episode 14), nodes owned by an allocator (episodes 7 and 26), searched and inserted into with nothing more exotic than array shuffles; - Why the whole structure hinges on one operation -- splitting a full node -- and how splitting on the way down is what makes a B-tree grow from the root and stay perfectly balanced without a single rotation;
- How to test a multi-key tree so the test proves the load-bearing invariant (an in-order walk comes out sorted) and proves zero leaks via
std.testing.allocator(episode 12); - Where B-trees genuinely live -- every relational database index, every filesystem you have ever used -- and when a simpler structure beats them;
- How this maps onto the equivalents in C, Rust, and Go, and why the fixed-size arrays make this one of the friendlier data structures to write in a language with real memory discipline.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- Episode 107 fresh in your head -- a B-tree is the deterministic sibling of the skip list, so if the "keep it sorted under constant mutation" problem and the descend-and-slide search are fuzzy, re-read that one first;
- Comfort with generics via comptime (episode 14) and allocators (episodes 7 and 26), since we size these nodes over an arbitrary
Tand a compile-time degree, and own every node explicitly; - The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees (this post)
Learn Zig Series (#108) - B-Trees
Last episode I ended on a deliberate fork in the road. The skip list solved "keep an ordered collection fast under constant mutation" by cheating with coins -- randomness did the balancing, and you got O(log n) in expectation, an average over the coin flips rather than a promise. I said the other answer was deterministic: enforce a strict structural rule, pay real work to maintain it on every insert, and get balance that is guaranteed instead of merely likely. Today we walk that deterministic road, and the structure at the end of it is the single most consequential data structure in all of applied computing that nobody outside the field has heard of -- the B-tree. Every SQL database index you have ever queried is one. The filesystem holding this file is built on one. Before we build ours, let me clear the small debt from episode 107.
Solutions to Episode 107 Exercises
Exercise 1 -- count how many nodes hold a value. Since our skip-list insert permitted duplicates, equal keys sit adjacent on the bottom level (forward[0]). So we do the usual descend to land just left of the first match, then walk the ground floor counting equals until one isn't. Same descent you wrote three times last episode, with a counting tail.
// Add inside SkipList(comptime T, comptime lessThan). Descend to the greatest node
// strictly less than `value`, then walk forward[0] counting equal keys. Because the
// ground floor is sorted, the moment a node is NOT equal we are past the run and stop.
fn count(self: *Self, value: T) usize {
var node = self.head;
var i = self.level;
while (i > 0) {
i -= 1;
while (node.forward[i]) |next| {
if (lessThan(next.value, value)) node = next else break;
}
}
var total: usize = 0;
var it = node.forward[0];
while (it) |n| : (it = n.forward[0]) {
// "equal" = neither less than the other. First non-equal ends the run.
if (lessThan(n.value, value) or lessThan(value, n.value)) break;
total += 1;
}
return total;
}
Exercise 2 -- a range query. Descend to the first node whose value is >= lo, then walk forward[0] collecting until we pass hi. This is exactly the machinery behind Redis's ZRANGEBYSCORE, and it falls out of the skip list almost for free because the bottom level is a fully sorted linked list.
// Descend using `lo` as the target, which lands node just left of the first value >= lo.
// Then sweep forward[0] appending everything in [lo, hi], stopping the instant we exceed hi.
fn range(self: *Self, lo: T, hi: T, out: *std.ArrayList(T)) !void {
var node = self.head;
var i = self.level;
while (i > 0) {
i -= 1;
while (node.forward[i]) |next| {
if (lessThan(next.value, lo)) node = next else break;
}
}
var it = node.forward[0]; // first node with value >= lo
while (it) |n| : (it = n.forward[0]) {
if (lessThan(hi, n.value)) break; // n.value > hi -> done
try out.append(n.value); // lo <= n.value <= hi
}
}
Exercise 3 -- turn the multiset into a proper map. The change is small but load-bearing: store an Entry{ key, value } in each node, order the towers by .key, and on insert, if the descent lands on a node whose key equals the incoming key, overwrite its value in place and return -- so len never grows on a re-put and nothing leaks. Here are the two methods that carry the difference (they slot into a skip list whose node value type is Entry and whose comparator is keyLess over the key type):
// put: the episode's insert with ONE added early exit. If the candidate at forward[0]
// has a key equal to ours, we mutate its value and leave -- no new node, len unchanged.
fn put(self: *Self, key: K, value: V) !void {
var update: [max_level]*Node = undefined;
var node = self.head;
var i = self.level;
while (i > 0) {
i -= 1;
while (node.forward[i]) |next| {
if (keyLess(next.value.key, key)) node = next else break;
}
update[i] = node;
}
if (node.forward[0]) |cand| {
if (!keyLess(cand.value.key, key) and !keyLess(key, cand.value.key)) {
cand.value.value = value; // same key -> update in place
return;
}
}
const lvl = self.randomLevel();
if (lvl > self.level) {
var j = self.level;
while (j < lvl) : (j += 1) update[j] = self.head;
self.level = lvl;
}
const fresh = try self.createNode(.{ .key = key, .value = value }, lvl);
var k: usize = 0;
while (k < lvl) : (k += 1) {
fresh.forward[k] = update[k].forward[k];
update[k].forward[k] = fresh;
}
self.len += 1;
}
// get: the plain descent, then one equality check on the candidate.
fn get(self: *Self, key: K) ?V {
var node = self.head;
var i = self.level;
while (i > 0) {
i -= 1;
while (node.forward[i]) |next| {
if (keyLess(next.value.key, key)) node = next else break;
}
}
const cand = node.forward[0] orelse return null;
if (!keyLess(cand.value.key, key) and !keyLess(key, cand.value.key)) return cand.value.value;
return null;
}
That's the debt paid. Notice the shape of exercise 3 -- "one comparator, expressed only through lessThan, equality faked as neither-less-than" -- because that exact discipline carries straight into today's tree. On to it.
Why pack many keys into one node
Start from the binary search tree you already half-know: one key per node, two children, and if you keep it balanced you get O(log n) everything. The height of a balanced binary tree over n keys is about log2(n). For a million keys that's around 20 levels, meaning a search touches roughly 20 nodes -- 20 separate hops through memory, and as we belaboured in episodes 107 and 34, every hop is a potential cache miss.
Now here is the B-tree's one big idea. Instead of one key per node, put many -- dozens, hundreds -- keys into a single node, kept sorted inside the node in a flat array. A node with m keys has m + 1 children, one for each gap between (and around) the keys. Because each node fans out into many children instead of just two, the tree gets dramatically shorter. The height is now about log_m(n): with, say, 100 keys per node, a billion keys fit in a tree only about five levels deep. Five hops in stead of thirty. And crucially, once you have hopped to a node, all its keys are packed contiguously, so you scan them with a cache-friendly linear or binary search over an array -- the kind of work modern CPUs devour.
That "make the node fat so the tree is short" idea is not an academic nicety. It is the entire reason B-trees exist. They were invented in 1970 at Boeing (Bayer and McCreight) for disk, where a single seek reads a whole block of thousands of bytes and the cost of that seek dwarfs the cost of examining the bytes once they're in memory. If one node maps to one disk block, then the number of nodes you visit is the number of disk seeks, and you want that number as small as physically possible. Same logic applies, one rung down, to the cache-line vs main-memory gap on a modern CPU. A B-tree is, at heart, a tree tuned to the size of whatever the expensive fetch happens to be.
The parameter that controls the fatness is the minimum degree, universally written t. The rule (Knuth's formulation, the one every textbook uses) is: every node except the root holds between t - 1 and 2t - 1 keys. The root may hold as few as one. A node is full when it has the maximum 2t - 1 keys. That single "between half and full" invariant is what guarantees the tree can never get lopsided -- there is a hard floor on how few keys a node may have, so the tree can never grow a spindly branch.
The node: fixed-capacity arrays over a comptime degree
Because the capacity 2t - 1 is known at compile time, we don't need heap-allocated slices for the keys and children the way the skip list needed for its variable-height towers. We use plain fixed-size arrays, sized by the comptime degree (episode 14 again -- generics carrying real weight). Each node itself is heap-allocated (we own a variable number of nodes), but its innards are stack-flat arrays.
const std = @import("std");
fn BTree(comptime T: type, comptime t: usize, comptime lessThan: fn (a: T, b: T) bool) type {
return struct {
const Self = @This();
const max_keys = 2 * t - 1; // a full node
const min_keys = t - 1; // the floor for every non-root node
// The node: a sorted run of `n` keys and (if internal) n+1 children. keys and
// children are fixed-capacity arrays -- sized at comptime, no per-node allocation
// for them. A `leaf` node simply ignores its children array.
const Node = struct {
keys: [max_keys]T = undefined,
children: [max_keys + 1]*Node = undefined,
n: usize = 0, // how many keys are actually live
leaf: bool = true,
};
root: ?*Node,
allocator: std.mem.Allocator,
len: usize,
fn init(allocator: std.mem.Allocator) Self {
return .{ .root = null, .allocator = allocator, .len = 0 };
}
};
}
t is a compile-time constant, so [2*t - 1]T is a concrete array type the compiler lays out with zero runtime cost. Pass t = 3 and every node holds 2 to 5 keys; pass t = 128 and you get the fat, disk-block-shaped nodes real databases use. The same source compiles to both -- this is comptime generics doing exactly what episode 14 promised, specialising the layout with no boilerplate and no macros.
Search: pick a key here, or descend one child
Searching a B-tree is the binary-search-tree walk generalised to fat nodes. Within a node, scan the sorted keys for your target. If you find it, done. If not, the scan stops at the first key greater than your target, and the child hanging in the gap just before that key is the only place the target could live -- descend into it. Fall off a leaf without a match and the key isn't present. Same "express equality with only lessThan" trick from the skip list.
// searchNode: linear-scan this node's sorted keys. `i` lands on the first key not less
// than `value`. If that key equals `value` we found it; otherwise descend children[i]
// (the subtree between keys[i-1] and keys[i]), or fail if this is a leaf.
fn searchNode(node: *Node, value: T) ?*Node {
var i: usize = 0;
while (i < node.n and lessThan(node.keys[i], value)) : (i += 1) {}
if (i < node.n and !lessThan(value, node.keys[i])) return node; // keys[i] == value
if (node.leaf) return null;
return searchNode(node.children[i], value);
}
fn contains(self: *Self, value: T) bool {
const r = self.root orelse return false;
return searchNode(r, value) != null;
}
I used a linear scan inside the node for clarity. For large t you'd swap it for a binary search over keys[0..n] -- the keys are sorted, so it's a textbook std.sort-style bisection, and it turns the per-node cost from O(t) into O(log t). For the small t I'll test with, linear is fine and honestly faster (branch predictors love short predictable loops), which is a recurring theme: the asymptotically-worse algorithm frequently wins at small sizes because the constants and the cache behaviour dominate.
The one operation that matters: splitting a full node
Everything interesting about a B-tree is in how it stays balanced, and the whole mechanism reduces to a single move: splitting a full node. When a node fills up (2t - 1 keys), we split it into two nodes of t - 1 keys each, and push its middle key up into the parent, where it becomes a separator between the two halves. The parent gains one key and one child. That's it -- that's the entire balancing act, no rotations anywhere.
// splitChild: parent.children[i] is FULL (2t-1 keys). Split it into two half-nodes and
// promote its median key up into `parent` at slot i. The left half keeps keys[0..t-1];
// the new right node `z` takes keys[t..2t-1]; the median keys[t-1] moves up.
fn splitChild(self: *Self, parent: *Node, i: usize) !void {
const child = parent.children[i];
const z = try self.allocator.create(Node);
z.* = .{};
z.leaf = child.leaf;
z.n = min_keys;
// z takes the upper t-1 keys...
var j: usize = 0;
while (j < min_keys) : (j += 1) z.keys[j] = child.keys[j + t];
// ...and, if internal, the upper t children that hang off those keys.
if (!child.leaf) {
j = 0;
while (j < t) : (j += 1) z.children[j] = child.children[j + t];
}
child.n = min_keys; // child keeps the lower t-1 keys; the median leaves
// Open a slot in parent.children for z, right after the child we split.
var k = parent.n + 1;
while (k > i + 1) : (k -= 1) parent.children[k] = parent.children[k - 1];
parent.children[i + 1] = z;
// Open a slot in parent.keys for the promoted median.
k = parent.n;
while (k > i) : (k -= 1) parent.keys[k] = parent.keys[k - 1];
parent.keys[i] = child.keys[t - 1];
parent.n += 1;
}
Read that against the skip list's splice and you see the family resemblance: a fixed amount of array shuffling to open a slot, then a couple of assignments. The subtlety is purely the bookkeeping of which keys and children go where -- get the + t offsets right and everything else is mechanical. Nota bene: splitting requires the parent to have room for one more key, which is why the insert algorithm below is careful to only ever descend into a node after ensuring it isn't full. That precondition is the quiet genius of the whole design.
Insert: split full nodes on the way down
Here is the move that makes B-trees so clean. We insert in a single top-down pass, and proactively split any full node we're about to descend into. Because we split full nodes before entering them, we can guarantee that whenever we need to split a child, its parent has room to accept the promoted key -- the "parent isn't full" precondition holds by construction. The only place the tree grows taller is when the root itself is full: we make a fresh empty root, hang the old root beneath it, split, and the height goes up by exactly one. This is why a B-tree grows from the root, not the leaves, and why every leaf always sits at the exact same depth. Perfect balance, guaranteed, not hoped for.
// insertNonFull: the node is guaranteed to have room. In a leaf, shift bigger keys right
// and drop `value` into place. In an internal node, find the child to descend, split it
// first if it's full (which may bump us one slot right), then recurse.
fn insertNonFull(self: *Self, node: *Node, value: T) !void {
if (node.leaf) {
var i = node.n;
while (i > 0 and lessThan(value, node.keys[i - 1])) : (i -= 1) {
node.keys[i] = node.keys[i - 1];
}
node.keys[i] = value;
node.n += 1;
} else {
var i: usize = 0;
while (i < node.n and lessThan(node.keys[i], value)) : (i += 1) {}
if (node.children[i].n == max_keys) {
try self.splitChild(node, i);
// The median we just promoted now sits at keys[i]; if value is bigger,
// it belongs in the NEW right half, one child over.
if (lessThan(node.keys[i], value)) i += 1;
}
try self.insertNonFull(node.children[i], value);
}
}
// insert: handle the empty tree and the full-root (height-growing) case, then delegate.
fn insert(self: *Self, value: T) !void {
if (self.root == null) {
const r = try self.allocator.create(Node);
r.* = .{};
r.keys[0] = value;
r.n = 1;
self.root = r;
self.len += 1;
return;
}
const r = self.root.?;
if (r.n == max_keys) {
// Root is full: grow the tree upward by one level.
const s = try self.allocator.create(Node);
s.* = .{};
s.leaf = false;
s.children[0] = r;
self.root = s;
try self.splitChild(s, 0);
try self.insertNonFull(s, value);
} else {
try self.insertNonFull(r, value);
}
self.len += 1;
}
Trace it once in your head with t = 2 (nodes hold 1 to 3 keys) inserting 1, 2, 3, 4, 5. You add 1, 2, 3 into the root until it's full. Inserting 4 finds the root full, so insert builds a new root above it, splits the old root -- 2 goes up alone, leaving [1] and [3] as children -- and then 4 lands in the [3] leaf making it [3, 4]. Add 5 and that leaf becomes [3, 4, 5], full but not yet split. The tree is two levels deep, both leaves at the same depth. That "both leaves at the same depth, always" is the invariant a plain BST cannot promise and a B-tree gives you for free.
Walking it in order, and tearing it down
To prove our tree is correct we need to read every key back out in sorted order, which is the classic in-order traversal generalised to fat nodes: for each key slot, first recurse into the child to its left, then emit the key; after the last key, recurse into the final child. And because we own every node (episode 7's rule: whoever allocates, frees), teardown is a post-order walk freeing children before their parent.
// inorder: for a node with n keys and n+1 children, interleave child-then-key, then the
// last child. Over a valid B-tree this yields every key in ascending order.
fn inorder(node: *Node, out: *std.ArrayList(T)) !void {
var i: usize = 0;
while (i < node.n) : (i += 1) {
if (!node.leaf) try inorder(node.children[i], out);
try out.append(node.keys[i]);
}
if (!node.leaf) try inorder(node.children[node.n], out);
}
fn collect(self: *Self, out: *std.ArrayList(T)) !void {
if (self.root) |r| try inorder(r, out);
}
// freeNode: post-order -- free all children first, then the node itself.
fn freeNode(self: *Self, node: *Node) void {
if (!node.leaf) {
var i: usize = 0;
while (i <= node.n) : (i += 1) self.freeNode(node.children[i]);
}
self.allocator.destroy(node);
}
fn deinit(self: *Self) void {
if (self.root) |r| self.freeNode(r);
self.root = null;
}
Note the child loop runs i <= node.n, not i < node.n -- an internal node with n keys has n + 1 children, and forgetting that off-by-one leaks the last subtree on every internal node. That's precisely the class of bug std.testing.allocator catches for us, so let's put it to work.
Testing: sorted output and zero leaks
The load-bearing invariant of a B-tree is "an in-order walk is sorted" -- if that holds, search and insert are provably placing keys in the right subtrees. So the test inserts a deliberately scrambled batch, collects them, and asserts the result is sorted and complete. Pair that with std.testing.allocator (episode 12) and we simultaneously get correctness and a guarantee that not one of those internal nodes leaked a subtree.
fn u32Less(a: u32, b: u32) bool {
return a < b;
}
test "b-tree: sorted in-order walk, membership, no leaks" {
var tree = BTree(u32, 3, u32Less).init(std.testing.allocator); // t=3: 2..5 keys/node
defer tree.deinit(); // testing.allocator fails the test if freeNode misses a subtree
// Insert out of order, with a duplicate, enough to force several splits.
const values = [_]u32{ 50, 20, 80, 10, 30, 60, 90, 5, 25, 35, 70, 85, 95, 15, 20 };
for (values) |v| try tree.insert(v);
try std.testing.expectEqual(@as(usize, values.len), tree.len);
// Membership: everything we inserted is found, and a gap value is not.
try std.testing.expect(tree.contains(35));
try std.testing.expect(tree.contains(5));
try std.testing.expect(!tree.contains(999));
// The invariant that proves the whole structure: in-order output is ascending.
var out = std.ArrayList(u32).init(std.testing.allocator);
defer out.deinit();
try tree.collect(&out);
try std.testing.expectEqual(values.len, out.items.len);
var i: usize = 1;
while (i < out.items.len) : (i += 1) {
try std.testing.expect(out.items[i - 1] <= out.items[i]);
}
}
That ascending-order assertion is the one I'd defend hardest in review, same as the skip list's forward[0] sweep last episode: it's a single check that fails loudly the instant any split misplaced a key or any descent chose the wrong child. And because a B-tree is fully deterministic -- no RNG anywhere -- this test does the same thing byte-for-byte on every run without our even having to seed anything. That determinism is the whole trade we made versus the skip list ;-)
Performance: the honest numbers
Search, insert, and delete are all O(log n) -- and unlike the skip list, that's a worst-case guarantee, not an expectation over coin flips, because the "every node at least half full" invariant hard-caps the height at roughly log_t(n). There is no pathological input, no unlucky seed, no cosmic-ray scenario that degrades it. You pay for that guarantee with real work on every insert (the proactive splits) versus the skip list's occassional coin flip, but the ceiling is rock solid.
The cache and disk story is where the B-tree pulls decisively ahead of every pointer-per-key structure we've built. A balanced binary tree over a million keys is ~20 pointer hops, each a likely cache miss. A B-tree with t = 64 (nodes up to 127 keys) over the same million keys is three levels deep -- three node fetches, and each node is a contiguous array the CPU prefetches and scans at full speed. When the nodes are sized to a disk block, those three fetches are three disk reads instead of twenty, and since a disk seek is on the order of a hundred thousand times slower than a memory access, that difference is the whole ballgame. The B-tree doesn't win on comparisons -- it does roughly the same number as any log-n tree -- it wins by making the expensive operation (the fetch) rare.
When does a B-tree lose? When your data is small and lives entirely in fast memory, and especially when it's read-only. If you have ten thousand integers you look up constantly and never change, a plain sorted array with binary search beats a B-tree flat -- it's one contiguous block, maximally cache-friendly, zero pointer chasing. The B-tree earns its complexity exactly when the dataset is large, mutates, and lives across a slow boundary (disk, or a cache hierarchy under memory pressure). That is a narrower niche than it sounds -- it just happens to be the niche that databases and filesystems occupy.
Where B-trees actually live
The list is short and staggering. Every major relational database -- PostgreSQL, MySQL/InnoDB, Oracle, SQL Server, SQLite -- stores its indexes as B-trees (specifically the B+ tree variant, where all real data hangs off the leaves and internal nodes hold only separator keys, with the leaves chained together for fast range scans). When you put an index on a column and your query gets a thousand times faster, a B-tree is what you built. Filesystems run on them too: NTFS (Windows), HFS+ and APFS (Apple), ext4's directory indexing, Btrfs (the "B" is literally for B-tree), XFS -- the directory-lookup and extent-mapping structures are B-trees, because a filesystem is exactly "a huge mutable sorted map that lives on disk", the B-tree's home turf. Key-value stores that aren't LSM-based (LMDB, BoltDB) are memory-mapped B-trees. When people say the B-tree is the most important data structure in systems programming, this is why -- it silently underpins essentially all persistent, indexed storage on the planet.
The B+ tree refinement is worth knowing because it's what production systems actually use: keep all values in the leaves, use internal nodes purely as a routing directory of separator keys, and thread the leaves together in a linked list. That gives you two things our plain B-tree doesn't optimise for -- a range scan becomes "find the start leaf, then walk the leaf chain" (no bouncing up and down the tree), and the internal nodes stay tiny and fit more separators per block, making the tree even shorter. The core split-and-promote mechanics you just wrote are identical; the B+ tree just relocates where the payloads sit.
How this compares to C, Rust, and Go
In C, the B-tree is honestly one of the nicer structures to hand-write, precisely because the node is fixed-size arrays rather than a web of individually-allocated links. You malloc one struct per node, and the keys and children live inline. The classic bugs are all in the index arithmetic -- the + t offsets in the split, the i <= n child count in traversal -- and C gives you no help: an off-by-one there is silent memory corruption or a leaked subtree with nothing to shout about it. Our Zig version is the same algorithm with the same array layout, but std.testing.allocator turns a leaked subtree into a failed test, and Zig's bounds checking in debug builds turns an index slip into a clean panic with a stack trace in stead of a corrupted heap you discover three hours later.
In Rust, the B-tree is famously the structure the standard library chose for its ordered map -- std::collections::BTreeMap is a B-tree, not a red-black tree, and the authors picked it explicitly for the cache behaviour we discussed. Writing your own in safe Rust is very tractable because the pointers all flow strictly parent-to-child with no back-references -- none of the mutual-aliasing pain that sent the doubly linked list into Rc<RefCell<>> back in episode 106. You still lean on Box for the child pointers and fight the borrow checker a little around moving keys during a split, but it's a pleasant fight compared to a doubly linked list. That the standard library's own map is a B-tree tells you how far the cache argument carries.
In Go, the standard library has no ordered map at all (its map is a hash table, unordered), so a B-tree is exactly the kind of thing you'd reach for a package like google/btree to get -- and Google's own in-memory B-tree package is widely used precisely because "sorted, mutable, cache-friendly" isn't otherwise on the menu. Go's generics (1.18+) finally let such a package be typed without interface{} boxing on every key, which for a structure whose entire value proposition is packing keys densely into cache-friendly arrays actually matters a great deal -- boxing would scatter your keys back across the heap and throw away the locality you built the tree to get. Our Zig version, with comptime T laid out inline and no GC, gives you that density by construction and pays no collector tax on the pointer-heavy internal nodes.
Exercises
- Add a
min(self: *Self) ?Tandmax(self: *Self) ?T. The smallest key lives at the far-left leaf -- from the root, keep descendingchildren[0]until you hit a leaf, then returnkeys[0]. The largest mirrors it downchildren[node.n]returningkeys[node.n - 1]. Handle the empty tree, and test both against a scrambled batch of insertions. - Implement a
height(self: *Self) usizethat returns the number of levels, and write a test that inserts a few hundred sequential integers with a smallt(say 2 or 3) and asserts the height stays within the theoreticallog_t(n)bound. This is the concrete payoff of the guaranteed-balance property -- prove the tree never grows taller than the math allows. - The hard one: implement
delete(self: *Self, value: T) bool. Deletion is the genuinely fiddly B-tree operation because a leaf can underflow belowt - 1keys and must borrow a key from a sibling or merge with one, which is the split in reverse. Follow the same top-down discipline as insert -- ensure a child has at leasttkeys before you descend into it, borrowing or merging as needed -- so you never have to walk back up. Test that after deleting an interior separator key, an in-order walk is still sorted andlenis correct, and thatstd.testing.allocatorreports zero leaks after merges free nodes.
Where this is heading
Step back and see the two answers side by side. The skip list kept an ordered collection balanced with randomness -- coins, expectation, no guarantees but wonderfully simple code. The B-tree keeps it balanced with a strict structural rule -- half-full nodes, split-on-descent, height bounded by math, balance guaranteed. Both got us O(log n); they just paid for it with different currencies.
Now notice something about that B-tree. Its power came from the fat node -- many keys, high fan-out, short tree. But what if you did the opposite? What if you squeezed the node back down toward a single key, a plain binary node like the humble BST, yet refused to give up the guaranteed balance? You'd have to smuggle the "how full is this node" information somewhere else, since the node no longer has room to hold several keys. The classic trick is to tag each node with a single bit -- think of it as a colour -- and enforce a couple of rules about how those colours may sit relative to each other, rules that turn out to be exactly a B-tree of a particular small degree wearing a binary disguise. When an insertion breaks a rule, you fix it locally with a small rotation and a recolouring rather than a full split. Same guaranteed-balance goal, reached from the binary side in stead of the fat-node side, and the two are provably the same structure viewed through different glass. That equivalence is one of the most satisfying "wait, those are the same thing?" moments in all of data structures, and it's where we go next. One node at a time, we keep climbing.
Bedankt en tot de volgende keer!
Leave Learn Zig Series (#108) - B-Trees to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#109) - Red-Black Trees
- Learn Ethical Hacking (#87) - The Future of Security - Quantum, AI, and Beyond
- Learn AI Series (#127) - AI Security
- Learn Zig Series (#108) - B-Trees
- Learn Ethical Hacking (#86) - Critical Infrastructure Security - Hospitals, Power Grids, Water
- Learn AI Series (#126) - Distributed Training
- Learn Zig Series (#107) - Skip Lists
- Learn Ethical Hacking (#85) - Defending Against AI-Powered Attacks - A Practical Guide
- Learn AI Series (#125) - GPU Programming Basics
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly