← Data structures & algorithms
Trees Keep ordered access predictable on disk

B+ trees and indexes

Keep paths short. Keep pages full.

SQLite keeps every table and index in your app’s database file as a B-tree of fixed-size pages, and MySQL’s InnoDB stores each table in a B+ tree ordered by its primary key. A query that says WHERE day BETWEEN 30 AND 80 reads a few pages down and then along the bottom of that tree. Let’s watch an orders index grow one day at a time, and then answer that query.

TypeScriptGoOne orders index, two implementations.

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 end
B-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.

B+ index

Split a full page. Read along the leaves.

Levels1
Pages read0
Days returned0

orders index, three keys a page

Days 12, 19, and 27 arrive

Nothing read or written yet
  1. empty
days none returned yet

Put day 12 in its leaf: [12]. Step 1.

Put day 12 in its leaf: [12]. Step 1.

01/ 06
Insert days into the orders index

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.

TypeScriptReading
archive.ts
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;
}
GoAlongside
archive.go
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.

A B+ tree leaf page is full when a new indexed record arrives. What should happen?

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.

B+ index over the orders archive
OperationTimeExtra spaceWhat it assumes
Look up one dayO(log_B n) pagesO(1)One page per level, root to leaf. B is the keys a page holds.
Read a range of k daysO(log_B n + k ÷ B) pagesO(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 dayO(log_B n) pagesO(1) amortizedWalk down to the leaf. An overflow splits at most one page per level and may grow a new root.
Scan every record insteadO(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.