Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Prefix Sums & Range Queries  ›  Lesson

B-Trees and the Disk

B-Trees 34 minWhen every node costs a disk read, make each node a full page and the tree only a few levels tall
You're building a piece ofQuarry — the analytics engine
This piece — page_search(): Finds a key on disk in as few page reads as the tree is tall.
Scenario A year of Quarry's per-second events no longer fits in memory, and every node the index visits is a disk page read. A B-tree packs each page with sorted keys so any lookup costs as many reads as the tree is tall, three or four even for billions of rows.
Your task
Build page_search(tree, key). A page is {"keys": [...], "children": [...]} with sorted keys; a leaf has "children": [], and an internal page with k keys has k + 1 children. Return {"found": True or False, "reads": pages_read}, counting one read for every page you look at and stopping as soon as the key is found. Example: on the lesson's three-level tree, page_search(tree, 57) → {"found": True, "reads": 3}.

B-Trees and the Disk

In short: on disk the unit of cost is a page read, not a comparison, so a B-tree packs hundreds of sorted keys into each node instead of one, keeping a million keys in about 3 levels instead of a binary tree's 20. Searching binary-searches inside each loaded page, then follows the child between the two keys that bracket the target. Inserting always goes into a leaf; when a page overflows it splits around its median key, which moves up into the parent — height only ever grows at the top, so every leaf stays at the same depth with no rebalancing pass.

Three B-tree pages stacked wide and shallow next to a tall thin binary tree over the same million keys: 3 disk reads against 20

Everything in this section so far has assumed the log fits in memory. Quarry's does, for a while. Then someone asks for a year of per-second events, which is 31 million rows with several fields each, and the index has to live on disk.

Disk changes the cost model, and the change is big enough to change which structure is best:

operation rough time
read a value already in memory ~0.0001 ms
read a page from an SSD ~0.1 ms
read a page from a spinning disk ~10 ms

A disk never hands you one value. It hands you a page, usually 4 to 16 KB, whether you wanted one number from it or all of it. So on disk the thing to count is page reads, not comparisons. Comparing keys inside a page you've already loaded is nearly free by comparison.

Why a binary tree is the wrong shape

A balanced binary search tree over a million keys is about 20 levels deep. On disk each node is a separate page, so every lookup is about 20 page reads, and a range scan hops between pages scattered all over the disk. The binary tree is short enough in memory, but it wastes almost every page it reads: it loads 16 KB to look at one key and two pointers.

The fix is to use the whole page. Put hundreds of sorted keys in each node, with a child pointer between every pair of neighbouring keys. A node with k keys has k + 1 children, and child i holds every key strictly between key i − 1 and key i. That's a B-tree. With about 256 children per page, a million keys fit in 3 levels, and the top level or two are read so often that they stay cached in memory. A lookup costs one or two real disk reads.

Put numbers on it. Change the fanouts, or the number of keys:

python

Searching

At each page, find where the key would go among the page's sorted keys, with a binary search that runs in memory. Either it's there, or you know exactly which child to read next. Here's a small B-tree with at most 3 keys per page. Each node below is one page on disk:

watch it move · graph

Each box is one page. A search reads one page per level, so the height of the tree is the number of disk reads.

Look for 57. Read the root page. 57 is bigger than 30 and smaller than 60, so it can only be in the middle child. 14 nodes, 13 edges, directed. At 30 60. Settled: 30 60.

reads = 1

1 / 6
What does this print?
n, fanout = 1_000_000, 100
levels, reach = 0, 1
while reach < n:
    reach *= fanout
    levels += 1
print(levels)

Inside a page, bisect picks the child. bisect_right(keys, x) counts the keys at most x, which is exactly the index of the child to follow:

What does this print?
from bisect import bisect_left, bisect_right
keys = [20, 40, 60]
print(bisect_right(keys, 45), bisect_left(keys, 40))

That equality check is easy to forget, because in a binary search tree you'd check the node before choosing a side. In a B-tree the keys in internal pages are real keys too:

broken — fix it

30 is stored in the root page, so this should print True. It prints False, because the search goes straight past the root to a leaf.

Now write the search yourself. The code turns the nested pages into a graph for drawing, then walks them. Change key and run it:

your code, drawn · graph

Your page search. Every highlighted page is one disk read.

Inserting: split, and grow at the top

A new key always goes into a leaf, in sorted position. If the leaf now holds more keys than a page allows, split it: the median key moves up into the parent, the keys to its left become one page, and the keys to its right another. If that makes the parent overflow, it splits too, and so on up.

watch it move · array

A full page takes one more key, then splits around its median. The median becomes a separator in the parent page.

A leaf page holding 25, 30 and 35, with room for at most 3 keys. Insert 33. 3 cells. At index 0.

1 / 4

When the root splits, a new root is made above it holding just the median, and the tree gains a level. That's the only way a B-tree gets taller. Because height is only ever added at the top, every leaf stays at the same depth. The tree is balanced by construction, with no rotations and no rebalancing pass. Step through the split arithmetic:

step through it
1keys = [25, 30, 33, 35]
2mid = len(keys) // 2
3up = keys[mid]
4left = keys[:mid]
5right = keys[mid + 1:]
6print(up, left, right)

❓ Cross-question — "Why not a sorted array on disk and binary search?" Lookups would be fine: about 20 reads for a million keys, fewer with caching. But inserting into the middle of a sorted array on disk shifts everything after it, O(n) page writes. A B-tree insert writes one leaf, plus a few pages when it splits. It keeps the sorted order, so it keeps range scans, and it makes inserting cheap.

How wide does a page need to be? A binary tree over a million keys is about 20 levels deep. A B-tree with fanout f is about log base f of a million:

fanout f levels for 1,000,000 keys
2 19.9
10 6
50 3.5
100 3
400 2.3

A hundred children per page is the smallest fanout that fits a million keys in three levels, since 100³ = 1,000,000. Real pages of 16 KB with 8-byte keys and pointers hold several hundred, so databases typically sit at 3 or 4 levels even for billions of rows.


Idioms & real-world patterns

Range scans, and why most databases use a B+ tree

A range query, "every key from 40 to 57", visits only the children whose key ranges overlap the query and skips the rest. That's the same total-cover, partial, disjoint reasoning as the segment tree, applied to a wider node. Most databases use a B+ tree variant: every record lives in the leaves, internal pages hold only separators, and each leaf points to the next leaf. A range scan then finds its first leaf with a normal search and reads leaves in order until it passes the end: one search, then one read per page of results.

Why the root is (nearly) free

The root page is read on every single lookup, so it's always in the cache, and the next level usually is too. On a three-level tree most lookups do a single real disk read, for the leaf.

Quick practice

What does this print?
n, fanout = 1_000_000, 1000
levels, reach = 0, 1
while reach < n:
    reach *= fanout
    levels += 1
print(levels)
broken — fix it

Splitting [10, 20, 30, 40] should push up 30 and leave 40 as the only key on the right, but this prints (30, [10, 20], [30, 40]) — 30 is still sitting in both the parent and a leaf.


🎯 Your turn

Build Quarry's page index: page_search(tree, key). A page is {"keys": [...], "children": [...]} with sorted keys. A leaf has "children": [], and an internal page with k keys has k + 1 children. Return {"found": True or False, "reads": pages_read}, where every page you look at counts as one read, and stop as soon as the key is found.

  • For the lesson's tree, page_search(tree, 57) → {"found": True, "reads": 3} and page_search(tree, 30) → {"found": True, "reads": 1}

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green means this piece of Quarry is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upHow tall must it be?

Write levels_needed(n, fanout) returning the smallest number of levels h >= 1 such that fanout ** h >= n: how many page reads a lookup needs if every page has fanout children. levels_needed(1_000_000, 100) → 3, levels_needed(1_000_000, 2) → 20.

b-treelog-time
DrillWhich pages were read?

Write search_path(tree, key) returning the list of pages read while searching for key, each written as its list of keys, in the order read. Stop at the page containing the key, or at the leaf where it would be. For the lesson's tree, search_path(tree, 64) → [[30, 60], [70, 80, 90], [62, 66]].

b-treesearching
BuildEvery key from lo to hi

Write range_scan(tree, lo, hi) returning {"keys": [...], "reads": r}: every stored key with lo <= key <= hi in increasing order, and the number of pages read. Read a child only if it could hold a key in range: child i holds keys strictly between keys[i-1] and keys[i] (no bound at the ends), so skip it when keys[i-1] >= hi or keys[i] <= lo. For the lesson's tree, range_scan(tree, 40, 57) → {"keys": [40, 42, 45, 50, 52, 57], "reads": 4}.

b-treerange-queries
BossGrow it from the top

Write build_btree(keys, max_keys). Start from one empty leaf, {"keys": [], "children": []}, and insert the (distinct) keys one at a time. Each insert walks down to a leaf (child bisect_right(page_keys, key)) and puts the key in sorted position. On the way back up, any page holding more than max_keys keys splits: mid = len(keys) // 2, the key at mid moves up to the parent, the left page keeps keys[:mid] and children[:mid + 1], and the right page gets keys[mid + 1:] and children[mid + 1:]. If the root splits, a new root holds just the median. Return the final tree. build_btree([10, 20, 30, 40], 3) → {"keys": [30], "children": [{"keys": [10, 20], "children": []}, {"keys": [40], "children": []}]}.

b-treenode-split
CapstoneIs this index trustworthy?

Interview-style. After a crash, Quarry reloads its index pages from disk and must check them before serving queries. Write is_valid_index(tree, max_keys) returning True only if all of these hold:

  1. every page's keys are strictly increasing;
  2. every key lies strictly between the separators above it (a key under child i of a page is greater than that page's keys[i-1] and less than its keys[i]);
  3. an internal page with k keys has exactly k + 1 children;
  4. every leaf is at the same depth;
  5. no page holds more than max_keys keys, and every page except the root holds at least max_keys // 2.

Constraints: up to 10^5 pages; aim for one pass, O(total keys). Examples: the lesson's tree with max_keys = 3 → True. The same tree with max_keys = 2 → False (page [70, 80, 90] is too full).

b-treebalanced-trees
ExplainWhy disk indexes are wide and shallow

A colleague proposes storing Quarry's on-disk index as a balanced binary search tree, one node per page, since "it's O(log n) either way". Explain why that's much slower in practice than a B-tree with hundreds of keys per page, and why a B-tree never needs rotations to stay balanced.

page_search.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
page_search(tree, key) → dictWhether the key is stored, and how many pages the search read.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.