梦兽编程
AI_SUITE

Building a B+Tree in Rust: What I Got Wrong Before Getting It Right

A walkthrough of design mistakes I made implementing a B+Tree in Rust — from treating all nodes as in-memory pointers to understanding database pages, buffer pools, and record IDs. For developers learning database internals or systems programming in Rust.

Building a B+Tree in Rust: What I Got Wrong Before Getting It Right

Starting with a fundamentally wrong design

I started building a B+Tree as part of a database project in Rust, inspired by QuillSQL and CMU’s Bustub. The idea seemed straightforward: build a tree, support insert and search. How hard could it be?

So I wrote my first data structure:

struct Node {
    key: String,
    value: String,
    children: Vec<Node>,
}

One key, one value, a list of children. I looked at it and thought — a node has a key, a value, and children. A tree is made of nodes. Makes sense, right?

Wrong. Fundamentally wrong.

Inner nodes and leaf nodes in a B+Tree serve entirely different purposes. Inner nodes store keys to separate ranges — they don’t hold actual data. Leaf nodes map keys to real data rows. And inner nodes store multiple keys, not just one. Each key points to a child node range.

My one-key-one-value design didn’t even match the basic definition of a B+Tree.

Second attempt: let the struct tell the truth

After realizing the problem, I redesigned:

enum NodeValue {
    Data(String),           // leaf node: actual data
    Pointer(Box<Node>),     // inner node: points to child
}

struct Node {
    keys: Vec<String>,
    values: Vec<NodeValue>,
    next: Option<Box<Node>>, // leaf node linked list
}

Now keys and values are vectors. The value enum distinguishes between data and child pointers. Leaf nodes are linked via next for range scans.

Looked decent. Then I wrote tree.insert("hello") — and Rust’s compiler blew up in my face.

It expected a Value type, not &str. Fixing this dragged me into From/Into traits, the orphan rule, covered and uncovered types. I wrote a separate post just about that rabbit hole. All because I insisted on a single struct for both leaf and inner nodes.

But a much bigger problem was waiting.

The real problem: nodes aren’t toys in memory

This design has one fatal assumption: all nodes live in memory. Inner nodes hold Box<Node> pointers directly to child nodes. That works for a toy implementation, but it’s completely wrong for a real database.

B+Tree nodes are fundamentally database pages. Pages can be in memory or on disk. Which page is where, when to read it in, when to flush it out — that’s the Buffer Pool’s job, not the B+Tree’s.

Inner nodes cannot hold memory pointers to other nodes. They can only hold a page_id.

The entire design needed to be rebuilt.

Third iteration: page-oriented design

Inner nodes point to the page where the child node lives:

struct InnerPage {
    keys: Vec<Key>,
    children: Vec<PageId>,
}

Leaf nodes need to point to the exact location of the data — not just which page, but which slot within that page:

struct RecordId {
    page_id: PageId,
    slot: u16,
}

struct LeafPage {
    keys: Vec<Key>,
    records: Vec<RecordId>,
    next_page_id: Option<PageId>,
}

Every time you need to read an inner or leaf node, you don’t dereference a pointer. You go through the buffer pool:

// fetch a page from the buffer pool
let guard = buffer_pool.fetch_page(page_id)?;

// interpret raw bytes as an inner or leaf page
let inner = InnerPage::from_bytes(guard.data());

// unpin when done
drop(guard); // guard auto-unpins on drop

Pages are a fixed 8192 bytes (PAGE_SIZE). The buffer pool decides whether a page sits in a memory frame or needs to be read from disk. Your B+Tree code doesn’t care — it only deals with page_id.

Where do inner node keys come from?

One question that stuck with me: how are inner node keys determined?

Your instinct might say the B+Tree chooses them itself. In reality, inner node keys come from outside — specifically, from leaf node splits.

When a leaf node overflows and splits, the split process promotes a key up to the parent node. That promoted key becomes the boundary separating the two child node ranges in the inner node.

This clicked for me only after studying QuillSQL’s implementation. QuillSQL’s key type looks like this:

pub struct Tuple {
    pub schema: SchemaRef,
    pub values: Vec<ScalarValue>,
}

Not a simple String — a tuple carrying a schema and Vec<ScalarValue>. Why?

Because database keys can be composite — like (user_id, created_at). You can’t represent a compound index with a single String. Tuple is the general abstraction that works for any index key definition.

My String keys are essentially a placeholder. They’ll break the moment I need composite indexes or type-aware comparisons.

Current state

The B+Tree struct exists. The buffer pool layer underneath works — allocating pages, reading/writing via guards, flushing to disk. The insert function is still empty.

What’s left on the implementation plan:

  1. Binary search: finding key insertion points within nodes
  2. Buffer pool allocation: allocating new pages through the buffer pool, not OS malloc
  3. Split logic: leaf overflow → split → promote key to parent
  4. Locking: page-level locks for concurrent access
  5. Root handling: the root can also be a leaf; tree height increases only when the root itself overflows

Three key lessons

First, your data structure must reflect real responsibilities. Inner nodes and leaf nodes serve different roles. Cramming both semantics into one struct only creates pain. Rust’s type system won’t let you get away with it anyway.

Second, the storage model dictates the design. If nodes are database pages rather than memory objects, pointers become page_ids. This is a decision you need to make before writing the first line of code.

Third, study a reference implementation. Without QuillSQL’s code to learn from, I would have wandered around key design for much longer. A good reference shows not just “how” but “why.”

The B+Tree has been around since the 1970s. But building one from scratch is the only way to truly understand what those database textbooks are saying in their deceptively short paragraphs.


This article draws from the author’s experience implementing a B+Tree in Rust, using QuillSQL as a reference. Follow “梦兽编程” for more Rust systems programming content.

Frequently Asked Questions

What is the most common design pitfall when implementing a B+Tree in Rust?

Starting by treating every node as an in-memory pointer: a one-key-one-value design doesn't even match the basic definition of a B+Tree—internal nodes store multiple separator keys and no data, while leaf nodes map data rows; more critically, assuming all nodes are in memory and using Box<Node> to hold child node pointers simply doesn't work in a real database.

Why are nodes essentially database pages?

Because a node may not be in memory but on disk; which page is where and when it is read in and written back is the Buffer Pool's responsibility, not the B+Tree's. So internal nodes cannot hold memory pointers; they can only hold page_id, and leaf nodes also need to know the slot within the page; the page size is fixed at 8192 bytes.

Where do the keys in internal nodes come from?

They are not chosen by the B+Tree itself; they come from the leaf node split process: when a leaf node is full and splits, a key is promoted to the parent node as the boundary value separating the ranges of the two child nodes. The author only fully understood this after looking at the QuillSQL implementation.

What are the three key lessons summarized in the article?

First, the data structure should reflect the real division of labor: internal nodes and leaf nodes are two different roles, so don't force both semantics into one struct; second, the storage model determines the design: when nodes are database pages rather than in-memory objects, pointers must be replaced with page_id; third, find reference implementations: QuillSQL can tell you not only "how to do it" but more importantly "why do it this way."