01 / The idea
Storage is read a page at a time.
A bakery keeps an archive of its orders, one record per day with orders, and the reports ask for stretches: days 30 to 80, last week, this quarter. The days arrive in order and never stop arriving.
A scan reads every record for every report. A sorted array answers the report with a binary search, but every new day that lands in the middle shifts everything after it. A binary search tree takes new days cheaply, but each key is its own node, and on disk each node you follow is another read.
A B+ tree puts many sorted keys into each fixed-size page, so one read gets a whole slice of the order. The records live in the bottom pages, the leaves, each linked to the next. The pages above hold only separators that say which page to read next. A tree of pages that branches hundreds of ways at each level is only three or four levels deep for millions of records.
02 / Name the rule
Sorted leaves, linked; separators above; every leaf at the same depth.
A leaf page holds records in key order and a link to the leaf with the next keys. An internal page holds separators and one more child than it has separators: the child left of a separator holds smaller keys, the child right of it holds keys from that separator up.
When an insert overfills a page, the page splits in two. A leaf copies the first key of its right half up to its parent as the new separator; the record stays in the leaf. An internal page moves its middle separator up. When the root splits, a new root grows above it.
The invariant: every leaf is the same number of pages below the root, every page except the root is at least half full (leaves in records, internal pages in children), and each separator is the smallest key in the subtree to its right. The tree grows at the top, not the bottom, which is why it never leans the way a plain search tree can.
page = root
while page is internal:
page = the child after the last separator ≤ key
read records in page, then page.next, until a key passes the endB-tree versus B+ treeWhere the records live
In a B-tree, records can sit in internal pages as well as leaves, so a lookup may stop early. In a B+ tree, only leaves hold records and the leaves are linked, so every lookup reaches a leaf and a range never climbs back up. Databases use the B+ form for the ranges.
03 / Follow one operation
Ten days arrive, then a report reads days 30 to 80.
Before you watch, predict which day first overfills a page, and how many pages the report reads. Pages here hold three keys so every split shows. The animation inserts the days in the order they arrived, splitting pages and growing the root, then looks up day 71 and reads days 30 to 80 along the leaves. Try it lets you insert, look up, or read a range yourself.
Split a full page. Read along the leaves.
orders index, three keys a page
Days 12, 19, and 27 arrive
Nothing read or written yet- empty
Put day 12 in its leaf: [12]. Step 1.
Put day 12 in its leaf: [12]. Step 1.
Days 12, 19, and 27 arrive.
Put day 12 in its leaf: [12]. Step 1.
Reduced motion: choose a scene to see its completed state.
Read this scene
Put day 12 in its leaf: [12]. Step 1.
Put day 12 in its leaf: [12]. Step 1.
Insert days into the orders index.
Watch restarts when you return. Step through keeps the selected trace. Try it inserts, looks up, or reads a range on the same ten days.
04 / Read the shape
The index owns pages. The archive decides what a record is.
Basic form is BPlusIndex: insert with splits, get, a range
along the leaf links, and the shape of the pages for drawing, all recording what they read. In the wild builds the orders index, reads a range of days, and checks it against
a scan.
BPlusIndex: sorted leaf pages linked left to right, separators above, a split that hands a key to the parent, and a new root when the root splits. Every read counts its pages.
type Page = {
leaf: boolean;
keys: number[];
entries: IndexEntry[]; // leaves only, one per key
children: Page[]; // internal pages only, one more than keys
next: Page | null; // leaves only: the leaf with the next keys
};
const leafPage = (): Page => ({ leaf: true, keys: [], entries: [], children: [], next: null });
// A B+ tree keeps every record in a leaf page, sorted, and links each leaf to the next.
// Internal pages hold separators: keys[i] is the smallest key in children[i + 1]. A page
// that overflows splits in two and hands a separator to its parent; when the root splits,
// a new root grows above it, so every leaf stays at the same depth.
export class BPlusIndex {
#root: Page = leafPage();
#size = 0;
#pages = 1;
#height = 1;
#pagesRead = 0;
get size(): number {
return this.#size;
}
get pages(): number {
return this.#pages;
}
/** Levels from the root down to the leaves. */
get height(): number {
return this.#height;
}
/** Pages read by lookups and ranges since the index was built. */
get pagesRead(): number {
return this.#pagesRead;
}
insert(entry: IndexEntry, steps?: IndexStep[]): boolean {
const record = validateEntry(entry, 0);
const path: Page[] = [];
let page = this.#root;
while (!page.leaf) {
path.push(page);
page = page.children[childFor(page, record.key)];
}
const at = lowerBound(page.keys, record.key);
if (page.keys[at] === record.key) return false;
page.keys.splice(at, 0, record.key);
page.entries.splice(at, 0, record);
this.#size++;
steps?.push({ kind: 'insert', key: record.key, keys: [...page.keys] });
let level = path.length;
let overflow: { separator: number; right: Page } | null =
page.keys.length > PAGE_SIZE ? this.#splitLeaf(page, level, steps) : null;
while (overflow) {
const parent = path.pop();
level--;
if (!parent) {
this.#root = {
leaf: false,
keys: [overflow.separator],
entries: [],
children: [page, overflow.right],
next: null
};
this.#pages++;
this.#height++;
steps?.push({ kind: 'grow', keys: [overflow.separator] });
break;
}
const slot = childFor(parent, overflow.separator);
parent.keys.splice(slot, 0, overflow.separator);
parent.children.splice(slot + 1, 0, overflow.right);
page = parent;
overflow = parent.keys.length > PAGE_SIZE ? this.#splitInternal(parent, level, steps) : null;
}
steps?.push({ kind: 'shape', levels: this.shape() });
return true;
}
get(key: number, steps?: IndexStep[]): IndexEntry | null {
const leaf = this.#leafFor(key, steps);
const at = lowerBound(leaf.keys, key);
if (leaf.keys[at] !== key) return null;
steps?.push({ kind: 'match', key });
return { ...leaf.entries[at] };
}
/** Every record with lower ≤ key ≤ upper, read down to one leaf, then along the leaf links. */
range(lower: number, upper: number, steps?: IndexStep[]): IndexEntry[] {
validateRange(lower, upper);
const found: IndexEntry[] = [];
let leaf: Page | null = this.#leafFor(lower, steps);
while (leaf) {
for (const entry of leaf.entries) {
if (entry.key > upper) return found;
if (entry.key >= lower) {
found.push({ ...entry });
steps?.push({ kind: 'match', key: entry.key });
}
}
leaf = leaf.next;
if (leaf) {
this.#pagesRead++;
steps?.push({ kind: 'next', keys: [...leaf.keys] });
}
}
return found;
}
/** Each level's pages, as their keys, root first. */
shape(): number[][][] {
const levels: number[][][] = [];
let row = [this.#root];
while (row.length) {
levels.push(row.map((page) => [...page.keys]));
row = row.flatMap((page) => page.children);
}
return levels;
}
#leafFor(key: number, steps?: IndexStep[]): Page {
let page = this.#root;
let level = 0;
for (;;) {
this.#pagesRead++;
steps?.push({ kind: 'page', level, keys: [...page.keys] });
if (page.leaf) return page;
page = page.children[childFor(page, key)];
level++;
}
}
#splitLeaf(page: Page, level: number, steps?: IndexStep[]): { separator: number; right: Page } {
const half = Math.ceil(page.keys.length / 2);
const right: Page = {
leaf: true,
keys: page.keys.splice(half),
entries: page.entries.splice(half),
children: [],
next: page.next
};
page.next = right;
this.#pages++;
// A leaf split copies the right page's first key up: the record stays in the leaf.
const separator = right.keys[0];
steps?.push({ kind: 'split', level, left: [...page.keys], right: [...right.keys], separator });
return { separator, right };
}
#splitInternal(
page: Page,
level: number,
steps?: IndexStep[]
): { separator: number; right: Page } {
const middle = Math.floor(page.keys.length / 2);
// An internal split moves the middle separator up; it is not kept on either side.
const separator = page.keys[middle];
const right: Page = {
leaf: false,
keys: page.keys.splice(middle + 1),
entries: [],
children: page.children.splice(middle + 1),
next: null
};
page.keys.pop();
this.#pages++;
steps?.push({ kind: 'split', level, left: [...page.keys], right: [...right.keys], separator });
return { separator, right };
}
}
// The child that can hold key: the first separator greater than key marks its right edge.
function childFor(page: Page, key: number): number {
let child = 0;
while (child < page.keys.length && key >= page.keys[child]) child++;
return child;
}
function lowerBound(keys: readonly number[], key: number): number {
let low = 0;
let high = keys.length;
while (low < high) {
const mid = low + Math.floor((high - low) / 2);
if (keys[mid] < key) low = mid + 1;
else high = mid;
}
return low;
} type page struct {
leaf bool
keys []int
entries []IndexEntry // leaves only, one per key
children []*page // internal pages only, one more than keys
next *page // leaves only: the leaf with the next keys
}
// BPlusIndex keeps every record in a leaf page, sorted, and links each leaf to the next.
// Internal pages hold separators: keys[i] is the smallest key in children[i+1]. A page that
// overflows splits in two and hands a separator to its parent; when the root splits, a new
// root grows above it, so every leaf stays at the same depth.
type BPlusIndex struct {
root *page
size int
pages int
height int
pagesRead int
}
func NewBPlusIndex() *BPlusIndex {
return &BPlusIndex{root: &page{leaf: true}, pages: 1, height: 1}
}
func (b *BPlusIndex) Size() int { return b.size }
func (b *BPlusIndex) Pages() int { return b.pages }
func (b *BPlusIndex) Height() int { return b.height }
func (b *BPlusIndex) PagesRead() int { return b.pagesRead }
// Insert adds a record and reports false when its key is already indexed.
func (b *BPlusIndex) Insert(entry IndexEntry) (bool, error) {
if err := validateEntry(entry, 0); err != nil {
return false, err
}
path := []*page{}
current := b.root
for !current.leaf {
path = append(path, current)
current = current.children[childFor(current, entry.Key)]
}
at := sort.SearchInts(current.keys, entry.Key)
if at < len(current.keys) && current.keys[at] == entry.Key {
return false, nil
}
current.keys = insertAt(current.keys, at, entry.Key)
current.entries = append(current.entries, IndexEntry{})
copy(current.entries[at+1:], current.entries[at:])
current.entries[at] = entry
b.size++
var separator int
var right *page
split := false
if len(current.keys) > PageSize {
separator, right = b.splitLeaf(current)
split = true
}
for split {
if len(path) == 0 {
b.root = &page{keys: []int{separator}, children: []*page{current, right}}
b.pages++
b.height++
break
}
parent := path[len(path)-1]
path = path[:len(path)-1]
slot := childFor(parent, separator)
parent.keys = insertAt(parent.keys, slot, separator)
parent.children = append(parent.children, nil)
copy(parent.children[slot+2:], parent.children[slot+1:])
parent.children[slot+1] = right
current = parent
split = false
if len(parent.keys) > PageSize {
separator, right = b.splitInternal(parent)
split = true
}
}
return true, nil
}
// Get finds one record by key.
func (b *BPlusIndex) Get(key int) (IndexEntry, bool) {
leaf := b.leafFor(key)
at := sort.SearchInts(leaf.keys, key)
if at == len(leaf.keys) || leaf.keys[at] != key {
return IndexEntry{}, false
}
return leaf.entries[at], true
}
// Range returns every record with lower ≤ key ≤ upper, read down to one leaf, then along the
// leaf links.
func (b *BPlusIndex) Range(lower, upper int) ([]IndexEntry, error) {
if err := validateRange(lower, upper); err != nil {
return nil, err
}
found := []IndexEntry{}
for leaf := b.leafFor(lower); leaf != nil; {
for _, entry := range leaf.entries {
if entry.Key > upper {
return found, nil
}
if entry.Key >= lower {
found = append(found, entry)
}
}
leaf = leaf.next
if leaf != nil {
b.pagesRead++
}
}
return found, nil
}
// Shape lists each level's pages as their keys, root first.
func (b *BPlusIndex) Shape() [][][]int {
levels := [][][]int{}
row := []*page{b.root}
for len(row) > 0 {
level := [][]int{}
next := []*page{}
for _, p := range row {
level = append(level, append([]int(nil), p.keys...))
next = append(next, p.children...)
}
levels = append(levels, level)
row = next
}
return levels
}
func (b *BPlusIndex) leafFor(key int) *page {
current := b.root
for {
b.pagesRead++
if current.leaf {
return current
}
current = current.children[childFor(current, key)]
}
}
func (b *BPlusIndex) splitLeaf(p *page) (int, *page) {
half := (len(p.keys) + 1) / 2
right := &page{
leaf: true,
keys: append([]int(nil), p.keys[half:]...),
entries: append([]IndexEntry(nil), p.entries[half:]...),
next: p.next,
}
p.keys = p.keys[:half:half]
p.entries = p.entries[:half:half]
p.next = right
b.pages++
// A leaf split copies the right page's first key up: the record stays in the leaf.
return right.keys[0], right
}
func (b *BPlusIndex) splitInternal(p *page) (int, *page) {
middle := len(p.keys) / 2
// An internal split moves the middle separator up; it is not kept on either side.
separator := p.keys[middle]
right := &page{
keys: append([]int(nil), p.keys[middle+1:]...),
children: append([]*page(nil), p.children[middle+1:]...),
}
p.keys = p.keys[:middle:middle]
p.children = p.children[: middle+1 : middle+1]
b.pages++
return separator, right
}
// childFor is the child that can hold key: the first separator greater than key marks its
// right edge.
func childFor(p *page, key int) int {
child := 0
for child < len(p.keys) && key >= p.keys[child] {
child++
}
return child
}
func insertAt(keys []int, at, key int) []int {
keys = append(keys, 0)
copy(keys[at+1:], keys[at:])
keys[at] = key
return keys
} Reading the TypeScriptPages as plain objects
A page is one object type for both kinds: leaves use entries and next, internal pages use children. splice does the
splits in place. A page counter tracks every page a lookup or range reads.
Reading the GoPointers to pages, and capped slices
Pages are pointers, so a split can hand the new right page to its parent. After a split, the left page’s slices are capped at their new length so a later append cannot write into the right page’s memory.
What would I normally use in application code?The database’s index
A database index: CREATE INDEX in SQLite or PostgreSQL gives you this structure
with crash safety, concurrency, and pages sized to the disk. Write your own only to learn
it, or for a storage engine that does not ship one.
05 / Try a decision
A leaf is full when the next day arrives.
A page has a fixed size, and the day that does not fit still has to go somewhere. Decide what the index does before the feedback tells you.
06 / Follow the cost
Count pages read, because pages are what storage delivers.
Here is every operation at a glance, with n records, B keys to a page, and k records in a range. The rest of this section is about why the logarithm’s base matters.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Look up one day | O(log_B n) pages | O(1) | One page per level, root to leaf. B is the keys a page holds. |
| Read a range of k days | O(log_B n + k ÷ B) pages | O(k) | One path down to the first leaf, then along the leaf links, plus the one leaf that shows the range has ended. |
| Insert a day | O(log_B n) pages | O(1) amortized | Walk down to the leaf. An overflow splits at most one page per level and may grow a new root. |
| Scan every record instead | O(n) | O(k) | No index to keep up, and every query reads everything. |
| Hold n records | — | O(n) | Every page but the root is at least half full after a split, so the pages take at most about twice the records’ space. |
In the animation, ten days made eight pages on three levels. Day 71 took three page reads, one per level. Days 30 to 80 took six: three down, then two leaf links for the records, and one more leaf to see that day 86 had passed the end. A scan read all ten records.
Three keys a page is a teaching size. A real page of 8 KB holds a few hundred keys, so a tree three levels deep with 400 keys a page reaches up to 64 million records, and any one of them is three page reads away. A binary search tree over the same records is about 26 levels deep.
07 / Give it a real job
Let the database keep the pages.
In a real database, the orders table has an index on the day column, the report’s BETWEEN becomes a descent and a leaf walk, and a new day’s order is an insert that
splits a page now and then. The pages are sized to the disk, and the upper levels usually stay
in memory, so most reads touch only the leaves.
What the example leaves out is a decision too. Days arrive in order here, so each split leaves the left page half empty; PostgreSQL notices inserts at the right edge and splits unevenly so the left page stays nearly full. Deletes can leave pages sparse, and real engines merge or rebalance them. And a database logs every page change before writing it, so a crash mid-split cannot corrupt the tree.
In frontend code you meet this through a query: a server’s BETWEEN, or an IDBKeyRange.bound over an IndexedDB index. The pages stay inside the database.
08 / Make the call
Choose by where the data lives and how it is asked for.
Reach for a B+ index when the data lives on storage read in pages, keeps growing, and is asked for by key and by range. That is almost every database table with a report behind it.
Look elsewhere when the question changes. Exact keys only, never ranges: a hash index. Data that fits in memory and changes often: a balanced binary search tree or an ordered map. A list that rarely changes: a sorted array and binary search. Writes that far outnumber reads: a log-structured store that sorts in the background.
09 / Take the idea with you
Explain it without saying “B+ tree.”
“I keep the records sorted in fixed-size pages, each pointing to the next. Above them, smaller pages hold just enough keys to say which page to read next. When a page overflows, I split it and pass a key up, and when the top page overflows, I add a new top. To read a range, I go down once and then along the bottom.” That describes the mechanism. The name is what you call it in a review.
Before moving on, explain three things without the name: why day 34 made the tree taller,
why the report read one more leaf than it returned records from, and why the tree never
leans the way a search tree fed sorted keys does. Then run EXPLAIN on a range query
in your own database and find the index it walks.
Connections to follow nextRelated lessons
- Binary search tree keeps one key per node in memory and rotates to stay short.
- Ordered map answers the same floor, ceiling, and range questions in memory.
- Binary search is how a page finds its key, and how a sorted array answers a range.