cld-toys › Toys › btree-index

Commentary: btree-index

The same 100,000 keys, indexed twice. Inserted in sorted order — the case everyone calls the best case — they build the worse index. A study guide for btree.py.

btree-index/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with btree.py open beside you. btree.py is the toy itself (194 lines: a Pager, a Node, a BPlusTree); demo.py builds the same 100,000 keys twice, in two insertion orders, into real files ascending.idx and random.idx; test_btree.py locks the numbers in. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0), arm64 Apple Silicon, Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd btree-index
python3 demo.py       # the aha (§6) — ~20s, writes two real index files
python3 test_btree.py # pins every claim this page makes — ~2s
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is a B+tree index living in one flat file of 1024-byte pages. It does two things: insert(key, value) and get(key). The Pager counts every page it fetches, which is the only cost this toy cares about.

The demo builds the same 100,000 keys twice. Once inserted in ascending order — the case everybody calls the best case — and once shuffled with a fixed seed. Same tree, same order 63, same key set, same code.

The sorted load produces the worse index. It is 38% bigger, its leaves are half empty, and at this N it is one level taller — so every lookup against it costs four page reads instead of three, forever.

By the end you should be able to:


2. The problem this mechanism exists to solve

You have a table with 100 million rows and a query that wants one of them by id. Scanning is out. So you build an index — a data structure that turns the key into a location.

The obvious answer from an algorithms course is a balanced binary search tree: O(log n) comparisons, and 100 million rows means about 27 of them. That answer is close to worthless on disk, and understanding why is the whole reason B-trees exist.

The observation the whole structure is built on A disk does not have a "read one node" operation. The smallest thing a block device will give you is a page — 4 KiB is the usual unit, and even NVMe cannot fetch you 16 bytes. So a binary tree with one node per page reads 4096 bytes to compare 8 of them, then does it again, 27 times. The node is not the cost. The page fetch is the cost, and a binary tree pays one per comparison.

That reframes the goal. You are not trying to minimise comparisons; you are trying to minimise page fetches, and you have a page's worth of bytes to spend on each one. A B-tree spends them by cramming hundreds of keys into one node, so a single fetch narrows the search by a factor of hundreds instead of two. It ends up doing far more comparisons than the binary tree and winning by a mile, because comparisons are free and page fetches are not.

Two goals then pull against each other, and both are defensible:

Every real engine picks a point on that line — Postgres defaults to an 8 KiB page filled to 90%, SQLite to 4 KiB — and the demo in §6 is about what happens when your insertion order picks a different point for you without asking.


3. Background you need

None of this is deep, but the commentary below leans on it.

ConceptWhere it's used hereOne source
Page / block as the unit of I/O Pager.read_page always fetches PAGE_SIZE bytes; pager.reads is the only cost metric in the toy SQLite file format §1.6, B-tree Pages
Branching factor (fanout) MAX_KEYS = 63, so a node has up to 64 children and the tree is 3 levels deep at 100,000 keys Use The Index, Luke — The B-Tree
B+tree vs. B-tree _split copies the separator up out of a leaf instead of moving it, so every key lives in a leaf Wikipedia: B+ tree
Node occupancy / fill factor The whole aha: stats()["fill"], 50.8% vs 69.8% Postgres CREATE INDEXfillfactor
Fixed-width record layout struct.Struct("!qq") — one record format for both leaves and internal nodes struct — format strings

The two that carry the result are the first and the fourth. If you only take one thing: the page is the unit of cost, and how full each page is is a property of your insertion order, not of the algorithm.


4. The mental model

Before any code. One file, sliced into fixed-size pages. A lookup is a walk down that file, one page fetch per level.

FILE: one flat array of 1024-byte pages. page id = byte offset / 1024 ┌──────────┬──────────┬──────────┬──────────┬──────────┬───── │ page 0 │ page 1 │ page 2 │ page 3 │ page 4 │ ... └──────────┴──────────┴──────────┴──────────┴──────────┴───── ONE PAGE, decoded: leaf? count first-child up to 63 slots, 16 bytes each ┌──┬────┬──────┬────────────────────────────────────────────┐ │ 1│ 2 │ 4 │ key₀ pay₀ │ key₁ pay₁ │ … │ key₆₂ pay₆₂ │ 0…│ └──┴────┴──────┴────────────────────────────────────────────┘ ╰── 7-byte header ──╯ ╰─ 63 × 16 = 1008 ─╯ 1015 of 1024 used in a LEAF: payᵢ is the value for keyᵢ in an INTERNAL: payᵢ is the page id of the child holding keys ≥ keyᵢ, and the header's "first-child" holds keys < key₀ LOOKUP of key 61234 in random.idx — the real page ids and the real keys on them, three fetches, and that is the entire structure: fetch #67 [ 1711 | 3412 | … | 60492 | 62099 | … | 98600 ] 62 keys └─ 60492 ≤ 61234 < 62099, go here fetch #2140 [ 60549 | 60607 | … | 61215 | 61266 | … | 62048 ] 34 keys └─ 61215 ≤ 61234 < 61266, go here fetch #397 [ 61215 | 61216 | … | 61234 | … | 61264 | 61265 ] 51 keys └─ found, slot 19

Two numbers govern everything that follows:

The demo is about the second number silently setting the first.


5. Reading the source

194 lines, three classes and one function. Read it in this order.

5.1 The page geometry, decided in four constants

btree.py · lines 16–19
PAGE_SIZE = 1024
MAX_KEYS = 63                     # "order 63": a node holds at most 63 keys
HEADER = struct.Struct("!BHI")    # leaf flag, key count, leftmost child pid
SLOT = struct.Struct("!qq")       # the one record format: key, payload

These four lines fix the shape of the whole tree, and the arithmetic is worth doing by hand because it is the arithmetic a DBA does on a real index.

HEADER.size is 7 (B = 1, H = 2, I = 4). SLOT.size is 16 (two q, signed 64-bit). So a full node is 7 + 63 × 16 = 1015 bytes, nine short of the 1024-byte page — the largest MAX_KEYS that fits. Bump it to 64 and the page overflows.

MAX_KEYS = 63 means fanout 64: an internal node with 63 keys has 64 children. That is what buys the shallow tree:

heightkeys it can hold, packed full
163
264 × 63 = 4,032
364² × 63 = 258,048
464³ × 63 = 16,515,072

A hundred thousand keys should therefore fit comfortably in height 3. Hold on to that; in §6 one of the two indexes doesn't manage it.

That table is a prediction, so I checked it by shrinking the page. Same 100,000 shuffled keys, MAX_KEYS varied:

=== D. fanout: MAX_KEYS, i.e. how much fits in one page === MAX_KEYS=3 page= 55B height=11 pages= 64801 reads for one lookup=11 MAX_KEYS=7 page= 119B height=7 pages= 23807 reads for one lookup=7 MAX_KEYS=15 page= 247B height=5 pages= 10363 reads for one lookup=5 MAX_KEYS=31 page= 503B height=4 pages= 4860 reads for one lookup=4 MAX_KEYS=63 page= 1015B height=3 pages= 2339 reads for one lookup=3

Halving the page adds a level, every time, and the page reads track the height exactly. MAX_KEYS=3 is essentially a binary tree — 11 reads, against log₂(100000) ≈ 17 for a true one — and the entire gain from 11 reads down to 3 came from making the page bigger, not from a better algorithm. That is the §2 argument, measured.

The ! prefix (network byte order) is not decoration. Native = layout would insert padding and make the byte layout depend on the machine; ! makes the file identical on any architecture, which is what a real database file format does for exactly the same reason.

5.2 Pager — the only thing in the toy that costs anything

btree.py · lines 43–51
    def read_page(self, pid):
        self.reads += 1
        self.f.seek(pid * PAGE_SIZE)
        return self.f.read(PAGE_SIZE)

    def write_page(self, pid, data):
        self.writes += 1
        self.f.seek(pid * PAGE_SIZE)
        self.f.write(data)

The whole disk model. Page id p is the bytes at offset p × PAGE_SIZE, and the counters are the point: reads is the metric every claim on this page is measured in.

Two deliberate absences.

There is no cache. A real engine has a buffer pool, and the root page of a hot index is never actually read from disk. Adding one here would have made reads count misses rather than fetches, and the number would then depend on the cache size and eviction policy rather than on the tree — which is the thing being measured. The toy keeps the honest, cache-free number and lets §8 explain what the buffer pool would change.

There is no fsync. Durability is a different mechanism, and mixing it in would double the toy's size while teaching neither idea properly.

alloc() (btree.py:38-41) hands out page ids from a monotonic counter and nothing is ever freed. That is what makes the file reproducible: build the same keys in the same order and you get the same bytes, which the demo relies on and test_the_index_file_is_byte_identical_across_builds asserts.

5.3 Node.pack — one record format, and the trick that allows it

btree.py · lines 72–76
    def pack(self):
        out = [HEADER.pack(self.leaf, len(self.keys), 0 if self.leaf else self.kids[0])]
        for key, payload in zip(self.keys, self.kids if self.leaf else self.kids[1:]):
            out.append(SLOT.pack(key, payload))
        return b"".join(out).ljust(PAGE_SIZE, b"\x00")

There is a real problem hiding here. A leaf with n keys has n values — pairs, which serialise beautifully. An internal node with n keys has n+1 children, which does not pair with anything.

The usual fix is two page formats, or a trailing odd field. This toy instead puts child 0 in the header — that is what the I in !BHI is for — so slot i always carries (keys[i], kids[i+1]) and both node types share one 16-byte record. It costs one confusing expression (self.kids[1:]) and buys a SLOT struct that never needs a branch, which is most of why the file is 194 lines and not 300.

ljust(PAGE_SIZE, b"\x00") is why a page is always exactly PAGE_SIZE bytes even when nearly empty. That zero padding is not waste in the file-format sense — it is what makes offset = pid × PAGE_SIZE arithmetic valid, and it is exactly what the "avg leaf fill" number in §6 is measuring the size of.

5.4 scan — the linear search, and the <=/< that is not a typo

btree.py · lines 89–98
def scan(keys, key, leaf):
    """Linear scan for `key`. Returns (slot index, comparisons performed).

    In a leaf: the first slot whose key is >= `key` — where `key` belongs.
    Inside:    the child to descend into, so keys[i-1] <= key < keys[i].
    """
    i = 0
    while i < len(keys) and (keys[i] < key if leaf else keys[i] <= key):
        i += 1
    return i, min(i + 1, len(keys))

Two judgement calls in one line, and only one of them is load-bearing.

Linear, not binary. Deliberate, so the comparison count is visible and countable: scan returns how many comparisons it did, which is what makes the second half of §6 measurable at all. Whether that costs anything is §7.1 — I ran it, and the answer surprised me.

keys[i] < key in a leaf, keys[i] <= key inside. These look interchangeable and are not. keys[i] in an internal node is the smallest key in child i+1, so a key exactly equal to a separator lives to its right — which means the scan must step past an equal separator, hence <=. In a leaf the same key is the one you want, so the scan must stop on it, hence <. Making them the same character is the single easiest way to write a B-tree that loses data. So I broke it on purpose:

=== C. internal descent uses <=; what if it used < ? === <= inside (as written) keys that no longer resolve: 0 < inside keys that no longer resolve: 2274 first few: [42, 81, 126, 173, 224]

2,274 keys silently vanish out of 100,000 — and that number is derivable, not arbitrary. The random index has 2,275 leaves (§6), so it has 2,274 separators sitting in internal nodes. Every separator, and only a separator, becomes unfindable. The descent walks left past its own boundary and lands in the leaf next door. get returns None, no error, no corruption you could detect from outside. This is the line I would put a test on first, and test_the_64th_key_splits_the_root_exactly_in_half does exactly that with its final assertion, tree.get(32) == 320.

The min(i + 1, len(keys)) in the return is bookkeeping: a scan that stops early made i + 1 comparisons (the failed ones plus the one that stopped it), and a scan that ran off the end made len(keys).

5.5 get — the shape of the entire structure

btree.py · lines 114–128
    def get(self, key):
        pid = self.root
        self.last_path = []
        while True:
            self.last_path.append(pid)
            node = Node.unpack(self.pager.read_page(pid))
            i, comps = scan(node.keys, key, node.leaf)
            self.comparisons += comps
            if not node.leaf:
                pid = node.kids[i]
                continue
            self.comparisons += 1
            if i < len(node.keys) and node.keys[i] == key:
                return node.kids[i]
            return None

One read_page per level and no backtracking — the loop only ever descends. That is the promise of the structure, and it means pager.reads for a lookup equals the height exactly, which test_a_lookup_reads_exactly_height_pages asserts at heights 1, 2 and 3.

There is no separate "does this key exist" pass. A B+tree lookup is unconditional descent to a leaf followed by one equality test, so a miss costs precisely the same as a hit — a property that matters for anyone reasoning about worst-case latency.

The extra self.comparisons += 1 before the equality test is honesty about cost: the final keys[i] == key is a real comparison and the totals in §6.5 include it.

5.6 _insert — the recursion that carries splits back up

btree.py · lines 151–161
        else:
            split = self._insert(node.kids[i], key, value)
            if split is None:
                return None       # nothing below changed shape: this page is untouched
            sep, right = split
            node.keys.insert(i, sep)
            node.kids.insert(i + 1, right)
        if len(node.keys) > MAX_KEYS:
            return self._split(pid, node)
        self.pager.write_page(pid, node.pack())
        return None

The return None on the third line is the whole write-amplification story of a B-tree. An insert reads a page at every level, but it only writes the pages it changed — usually just the leaf. If nothing below split, this internal node's bytes are unchanged and rewriting them would be a page of I/O for no reason. I checked what it saves:

=== E. `if split is None: return None` — skipping the clean write === as written writes= 104673 pages=2339 file sha=72c6ead1a74aa74d always write back writes= 299582 pages=2339 file sha=72c6ead1a74aa74d

Identical file, 2.9× the writes. So this line is worth nothing to correctness and a great deal to I/O — which is exactly the kind of line worth knowing about before you "simplify" it away. 104,673 writes for 100,000 inserts is close to the floor of one per insert, and the excess decomposes exactly: 1 initial root page + 100,000 leaf writes + 2,336 splits (2 writes each, replacing the 1 they'd have done) + 2,334 parents that absorbed a split without splitting themselves + 2 root growths = 104,673.

len(node.keys) > MAX_KEYS also deserves a look: the node is allowed to become overfull in memory and is split afterwards. Insert-then-split is several lines shorter than checking for room first, and since the page is only ever written after the check, an over-63-key node never reaches disk.

5.7 _split — the four lines the whole demo is about

btree.py · lines 163–178
    def _split(self, pid, node):
        """Halve an overfull node in place; the new right half gets a fresh pid."""
        mid = len(node.keys) // 2
        sep = node.keys[mid]
        if node.leaf:
            # The separator is *copied* up: a B+tree keeps every key in a leaf.
            left = Node(True, node.keys[:mid], node.kids[:mid])
            right = Node(True, node.keys[mid:], node.kids[mid:])
        else:
            # The separator *moves* up; the children either side of it split.
            left = Node(False, node.keys[:mid], node.kids[:mid + 1])
            right = Node(False, node.keys[mid + 1:], node.kids[mid + 1:])
        rpid = self.pager.alloc()
        self.pager.write_page(pid, left.pack())
        self.pager.write_page(rpid, right.pack())
        return sep, rpid
The load-bearing line mid = len(node.keys) // 2 is the line the entire demo exists to interrogate. A node overflows at 64 keys, so mid is 32 and both halves come out at 32 keys — 32/63 = 50.79% full. Nothing about a B-tree requires this. It is the obvious choice, it is provably fine for random input, and §6 shows it being the wrong choice for sorted input.

Leaves copy the separator up; internal nodes move it. Look at the slices: the leaf's right starts at keys[mid:], so sep is still in the leaf and now also in the parent. The internal node's right starts at keys[mid + 1:], so sep has left this level entirely. That difference is the difference between a B+tree and a B-tree, and getting it wrong loses data the same silent way §5.4 did:

=== H. leaf split copies the separator up; what if it moved it? === copy up (as written) keys that no longer resolve: 0 move up (B-tree) keys that no longer resolve: 2244 first few: [42, 81, 126, 173, 225]

(A real B-tree stores a value alongside the separator in the internal node, so nothing is lost. Dropping the value while moving the key, as the variant does, is the mistake — and it costs you one key per split.)

Note also that left is written back to the same page id. A split creates exactly one new page, not two, so the parent's existing downlink stays valid and only one new pointer has to be threaded in.


6. The demo, and what it proves

demo.py builds two indexes over the same key set. The only difference is the order the keys arrive in:

demo.py · lines 42–44
    ascending = list(range(N))
    shuffled = list(range(N))
    random.Random(SEED).shuffle(shuffled)

Both files are real; the demo leaves ascending.idx and random.idx behind so you can ls -l them.

python3 demo.py
=== (1) 100,000 keys, order 63, two insertion orders === load height pages leaves file avg leaf fill ascending 4 3222 3125 3.15M 50.8% random(seed 42) 3 2339 2275 2.28M 69.8% === (2) where the tree got taller === ascending height 2 at insert 64, height 3 at insert 2,080, height 4 at insert 68,608 random(seed 42) height 2 at insert 64, height 3 at insert 2,693 === (3) the same lookup, key 61,234, in both indexes === ascending 4 page reads, 78 key comparisons -> 612340 #2211 (root, 1 key) -> #2210 (inner, 60 keys) -> #1971 (inner, 32 keys) -> #1972 (leaf, 32 keys) random(seed 42) 3 page reads, 76 key comparisons -> 612340 #67 (root, 62 keys) -> #2140 (inner, 34 keys) -> #397 (leaf, 51 keys) === (4) every one of the 100,000 keys, looked up === load reads/lookup comparisons: min avg max ascending 4 5 62.0 149 random(seed 42) 3 4 75.1 157 a balanced binary tree over 100,000 keys: ~17 reads and ~17 comparisons

The sorted load lost. Not marginally: 38% more pages (3222/2339 = 1.3775) and 33% more page reads on every lookup for the life of the index (4/3 = 1.3333). Sorting your load file made your queries slower.

6.1 Where the 50.8% comes from

Ascending inserts only ever touch the rightmost leaf — every new key is larger than every key already in the tree. So:

  1. The rightmost leaf fills to 63 keys.
  2. The 64th key overflows it. mid = 64 // 2 = 32, so it splits 32/32.
  3. The left half is now closed forever. No key smaller than its largest will ever arrive again, so nothing will ever be inserted into it. It is frozen at 32 of 63 slots.
  4. The right half becomes the new rightmost leaf. Go to 1.

Every leaf is sealed at exactly 32 keys the moment it stops being the rightmost one. 32/63 = 0.507936…, and test_sorted_load_builds_a_worse_index asserts the measured fill is exactly 32/63, not approximately.

The leaf count falls straight out. Splits happen at inserts 64, 96, 128, …, i.e. at 64 + 32k. Since 100000 = 64 + 32 × 3123, the hundred-thousandth insert is itself a split, and the tree ends with 2 + 3123 = 3125 leaves — which is precisely what the demo prints, and 3125 × 32 = 100,000 accounts for every key.

6.2 Where the 69.8% comes from

Random inserts land all over the tree, so a leaf that split is just as likely to be inserted into again as any other. A leaf therefore lives between 32 and 63 keys, and the average across a large tree converges to a famous constant: ln 2 = 0.693…, the classic result for B-trees built by random insertion (Yao, On Random 2-3 Trees, Acta Informatica 9, 1978).

That is not a hand-wave here. test_sorted_load_builds_a_worse_index builds 20,000 shuffled keys and measures a fill of 0.69314480 against ln 2 = 0.69314718 — agreement to six decimal places, on a tree of 458 leaves. The 100,000-key run in the demo sits a little higher at 69.8%, which is the usual sampling wobble at a particular N; the test asserts the convergence at the N where it is cleanest.

So: 32 keys per leaf versus ~44. 3125 / 2275 = 1.374, and there is the 37% extra pages.

6.3 How fill turns into height

Pages are cheap; a level is not. The extra level is what makes this permanent.

The root can hold 64 children. Each level-1 node, once closed by an ascending 50/50 split, holds 33 children (an internal node overflows at 64 keys/65 children and splits into 32 keys/33 children plus 31 keys/32 children). Each closed leaf holds 32 keys. So a height-3 ascending tree tops out at roughly:

64 root slots × 33 leaves per level-1 node × 32 keys per leaf = 67,584 keys

Measured crossing: 68,608. The 1,024-key gap is the right-hand spine, whose nodes are still filling toward 64 children rather than sitting at the closed 33 — 1,024 keys is exactly 32 more leaves' worth, and the ascending tree only ever has one partially-filled node per level.

Compare against §5.1's table: a packed height-3 tree holds 258,048 keys. The ascending tree gives up at 68,608 — 26% of the theoretical capacity, because it filled everything to about half and half-filling twice over (leaves and internal nodes) compounds.

The random tree, at 69.3% fill on both levels, keeps going. I ran the sweep to find out how far:

=== Boundary: at which N does each order grow a level? === ascending reaches height 4 at insert 68,608 random reaches height 4 at insert 148,074

100,000 sits between the two. That is why the demo shows 4 reads versus 3.

6.4 The boundary condition — when the height gap vanishes

The honest version of the headline The extra level is a threshold effect, not a universal law. Height is a step function; the two loads step at different insert counts, so the gap exists only in the windows between one tree's step and the other's.

From the two measured growth lists:

keys insertedascending heightrandom heightgap?
1 – 6311no
64 – 2,07922no
2,080 – 2,69232yes
2,693 – 68,60733no
68,608 – 148,07343yes
148,074 – …44no (until the next step)

The gap holds for 613 + 79,466 = 80,079 of the first 148,073 values of N, or 54% — a coin flip. Pick N = 50,000 and the demo's headline evaporates: both trees are height 3 and every lookup costs 3 reads in both. Pick N = 100,000, as the demo does, and it is stark.

What does not vanish at any N is the size: the ascending index is ~38% bigger, always, because 32/63 versus ln 2 is a ratio, not a threshold. Every one of those extra pages is a page of buffer pool the index will occupy forever, evicting something else. The height penalty is intermittent; the memory penalty is permanent. Do not carry away "sorted inserts cost you a level" — carry away "sorted inserts waste a third of your index, and at some values of N that also buys you a level."

6.5 Why 3 page reads and 75 comparisons beats 17 and 17

Section (4) of the demo is the second measured fact, and it is the one that explains why B-trees exist at all.

Every one of the 100,000 lookups against the random index cost exactly 3 page reads — the reads/lookup column is a single value, not a range, because get never backtracks. The comparisons ranged from 4 to 157, average 75.1. The upper bound is 3 nodes × 63 keys + 1 equality test = 190; 157 is what a real linear scan through mostly-69%-full nodes actually costs.

A balanced binary tree over the same 100,000 keys does log₂(100000) = 16.6, call it 17, of each. So relative to the BST the B+tree does:

extra comparisons = 75.1 − 16.6 = 58.5 page reads saved = 16.6 − 3 = 13.6 break-even = 58.5 / 13.6 = 4.3

The B+tree wins as long as one page read costs more than 4.3 key comparisons. That is the entire argument, and it needs no hardware numbers to be convincing — a page read involves a syscall at absolute minimum, and an integer comparison is a single instruction. Published latency figures put a random SSD read in the tens of microseconds and an integer comparison in the nanoseconds, three to four orders of magnitude apart (I did not measure those here; the break-even ratio above is the part derived from this toy). The B-tree is not winning by 20%. It is winning by four orders of magnitude of margin on a threshold of 4.3.

One inversion worth noticing, because it looks like a contradiction: the ascending index does fewer comparisons (62.0) than the random one (75.1), while doing more page reads. Its nodes are half empty, so there is less to scan in each one — 62.0/4 = 15.5 comparisons per node against 75.1/3 = 25.0. It is a perfect illustration of the trade the whole structure is built on: the sorted index optimised the free resource and paid in the expensive one.

6.6 The output is reproducible, and so are the files

python3 demo.py > run1.txt; shasum -a 256 ascending.idx random.idx
python3 demo.py > run2.txt; shasum -a 256 ascending.idx random.idx
diff run1.txt run2.txt && echo IDENTICAL
IDENTICAL 8a4bfac871ce5e2914057158edc31f7e8e0ff50d8157357c6ad057057c9b7bb8 ascending.idx 72c6ead1a74aa74d182a367b64294837025ca7c8e014b3859d3fb31de55c8d62 random.idx

Both index files are byte-identical between runs. The only randomness in the toy is random.Random(42).shuffle in demo.py; page ids come from a monotonic allocator, !-prefixed structs remove any machine dependence, and padding is explicit zeros. If you get different digests, something in this page is wrong and you should trust your run.

6.7 The tests

python3 test_btree.py
PASS test_a_node_serializes_to_exactly_one_page PASS test_scan_returns_the_slot_and_counts_its_comparisons PASS test_every_key_is_retrievable_and_absent_keys_are_not PASS test_the_64th_key_splits_the_root_exactly_in_half PASS test_a_lookup_reads_exactly_height_pages PASS test_reinserting_a_key_updates_it_in_place PASS test_sorted_load_builds_a_worse_index PASS test_the_index_file_is_byte_identical_across_builds All 8 tests PASSED

test_sorted_load_builds_a_worse_index is §6 shrunk to 20,000 keys so it runs in two seconds. It asserts the exact 32/63 fill, the ln 2 convergence, and the page counts — and it asserts that at 20,000 keys both trees are height 3, so the test itself refuses to let §6.4's threshold caveat rot.


7. Design decisions and roads not taken

7.1 Linear scan inside a node, not binary search

The obvious criticism of scan is that it is O(63) where bisect is O(log 63). It is also, on this evidence, not worth fixing. I swapped it for a hand-written binary search that counts every real comparison, and ran the same 10,000 lookups:

=== B. linear scan vs binary search inside a node === linear (as written) 10,000 lookups: 30000 page reads, 750151 comparisons, 0.45s linear (as written) height=3 pages= 2339 leaves= 2275 fill= 69.8% growth=[(64, 2), (2693, 3)] binary search 10,000 lookups: 30000 page reads, 178015 comparisons, 0.40s binary search height=3 pages= 2339 leaves= 2275 fill= 69.8% growth=[(64, 2), (2693, 3)]

572,136 comparisons eliminated — 4.2× fewer — and the tree, the page reads and the wall clock are all unchanged. The 0.45s/0.40s above is not a real 0.05s saving; it is noise. Repeating the pair four more times:

linear (as written) 10,000 lookups: 30000 page reads, 750151 comparisons, 0.51s binary search 10,000 lookups: 30000 page reads, 178015 comparisons, 0.43s linear (as written) 10,000 lookups: 30000 page reads, 750151 comparisons, 0.46s binary search 10,000 lookups: 30000 page reads, 178015 comparisons, 0.44s linear (as written) 10,000 lookups: 30000 page reads, 750151 comparisons, 0.44s binary search 10,000 lookups: 30000 page reads, 178015 comparisons, 0.46s linear (as written) 10,000 lookups: 30000 page reads, 750151 comparisons, 0.43s binary search 10,000 lookups: 30000 page reads, 178015 comparisons, 0.43s

Linear ranged 0.43–0.51s and binary 0.43–0.46s; on the third pair the binary search was slower. Deleting three quarters of the comparisons is unmeasurable. In this toy the cost is Node.unpack decoding 63 structs per page, and the comparisons are lost in the noise beside it. That is §6.5's point arriving from the other direction: comparisons really are the cheap resource.

Linear stayed because it makes the comparison count countablescan returning its own comparison total is what makes the second half of §6 possible. Real engines do use binary search (Postgres's _bt_binsrch), for a reason this toy can't show: with 8 KiB pages and hundreds of keys per node, the constant stops being invisible.

7.2 The 50/50 split, and the heuristic that fixes it

This is the counterfactual that carries the whole page, so I built it. The variant keeps everything else identical and changes one rule: if the overfull leaf is the rightmost one — the key just inserted is its largest — split 63/1 instead of 32/32.

=== A. is the 50/50 split point the cause? === ascending (50/50, as written) height=4 pages= 3222 leaves= 3125 fill= 50.8% growth=[(64, 2), (2080, 3), (68608, 4)] random (50/50, as written) height=3 pages= 2339 leaves= 2275 fill= 69.8% growth=[(64, 2), (2693, 3)] ascending (rightmost 63/1) height=3 pages= 1637 leaves= 1588 fill=100.0% growth=[(64, 2), (4033, 3)] random (rightmost 63/1) height=3 pages= 2365 leaves= 2305 fill= 68.9% growth=[(64, 2), (2629, 3)]

That inverts the entire result. The ascending load goes from the worst index in the experiment to the best one: height 3, 1,637 pages, 100% leaf fill — half the pages of the 50/50 ascending build and 30% fewer than the random build. And the random load barely notices (2,365 pages against 2,339, a 1% regression), because its overfull leaf is almost never the rightmost one.

So a heuristic that costs one comparison, applies to a fraction of splits, and cannot meaningfully hurt anyone, turns the worst case into the best case. Which is exactly why Postgres has it. From nbtsplitloc.c:

If the page is the rightmost page on its level, we instead try to arrange to leave the left split page fillfactor% full. […] when we are inserting successively increasing keys (consider sequences, timestamps, etc) we will end up with a tree whose pages are about fillfactor% full, instead of the 50% full result that we'd get without this special case.

"Instead of the 50% full result" is the number in the demo's first table, named in the source of a production database. Postgres's default fillfactor is 90 rather than my variant's 100, which leaves slack for the occasional out-of-order insert; CREATE INDEX documents it as applying "when extending the index at the right (adding new largest key values)."

The toy keeps the naive 50/50 split. The whole point is to see the failure that the heuristic exists to prevent, and a toy that quietly fixes it teaches you nothing about why real B-tree code has a special case for the right edge.

7.3 B+tree, not B-tree

A plain B-tree stores values in internal nodes too. A B+tree keeps every value in a leaf and uses internal nodes purely as a routing directory. Two consequences, both visible in _split (§5.7):

Essentially every database index is a B+tree, for the first reason.

7.4 No page cache

Adding a buffer pool would be twenty lines and would destroy the measurement (§5.2). More importantly, it would change what the toy is about: with a cache, the interesting number becomes hit rate, which is a different mechanism with its own trade-offs. reads here is a count of logical page fetches, which is the number a query planner reasons about, and the one that scales with height.

7.5 Fixed-width 64-bit keys

Real indexes key on strings, composite tuples, and NULLs, and every one of those forces variable-length records, a slot array with pointers into a cell heap, and a "does this still fit" calculation on every insert. That is where most of a real B-tree implementation's code lives (see SQLite's cell format, §10) — and none of it is the mechanism this toy is about. One struct and one record format is what keeps the file at 194 lines.

The cost is honest and worth stating: with variable-length keys, "the node is full" stops being len(keys) > 63 and becomes a byte-budget question, and the split point stops being len // 2 and becomes a search for the byte-wise midpoint. Postgres's nbtsplitloc.c is 1,200 lines almost entirely because of this.


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Each answer is derivable from the source, and each was verified by running it.

Question 1

§5.1 says a height-3 order-63 tree holds 258,048 keys. The ascending index gave up and grew to height 4 at 68,608. Where did the other 189,440 go?

Answer

Into padding, at two levels, multiplicatively.

258,048 = 64 × 64 × 63 assumes every node is packed: 64 children at the root, 64 at level 1, 63 keys per leaf. The ascending build achieves none of that. Its closed leaves hold 32 keys, and its closed internal nodes hold 33 children (a 64-key internal node splits into 32 keys/33 children and 31/32). So:

64 × 33 × 32 = 67,584

against a measured crossing of 68,608 — the extra 1,024 is the right-hand spine, whose one node per level is still filling toward 64 rather than sitting at the closed 33.

The point is that the shortfall is a product, not a sum. Half-full leaves alone would cost you 2×; half-full internal nodes alone would cost you ~2×; together they cost you nearly 4×, and 258,048/67,584 = 3.8. This is why fill factor is a first-class knob in every database and not a footnote.

Question 2

Change mid = len(node.keys) // 2 so that a rightmost leaf splits 63/1 instead of 32/32. Predict the ascending index's height, page count and fill. Then predict what happens to the random index.

Answer

Ascending goes to height 3, 1,637 pages, 100.0% fill — from the worst index in the experiment to comfortably the best, better even than the random build's 2,339 pages. Random is essentially unaffected: 2,365 pages against 2,339, about 1% worse, because a random insert almost never overflows the rightmost leaf.

=== A. is the 50/50 split point the cause? === ascending (50/50, as written) height=4 pages= 3222 leaves= 3125 fill= 50.8% growth=[(64, 2), (2080, 3), (68608, 4)] random (50/50, as written) height=3 pages= 2339 leaves= 2275 fill= 69.8% growth=[(64, 2), (2693, 3)] ascending (rightmost 63/1) height=3 pages= 1637 leaves= 1588 fill=100.0% growth=[(64, 2), (4033, 3)] random (rightmost 63/1) height=3 pages= 2365 leaves= 2305 fill= 68.9% growth=[(64, 2), (2629, 3)]

Watch growth too: the ascending tree's move to height 3 slides from insert 2,080 out to 4,033, because full leaves mean each level-1 node covers twice the key range. That is the whole mechanism in one number.

This is not a clever idea I had — it is what Postgres does, and §7.2 quotes nbtsplitloc.c saying so in almost these words.

Question 3

scan uses keys[i] < key in a leaf and keys[i] <= key inside. Make them both <. How many of the 100,000 keys stop resolving, and which?

Answer

Exactly 2,274, and they are exactly the separator keys.

=== C. internal descent uses <=; what if it used < ? === <= inside (as written) keys that no longer resolve: 0 < inside keys that no longer resolve: 2274 first few: [42, 81, 126, 173, 224]

The count is derivable: the random index has 2,275 leaves, so 2,274 boundaries between them, so 2,274 keys promoted into internal nodes as separators. In a B+tree, keys[i] in an internal node is the smallest key in child i+1, so a search key equal to it must go right. With < it goes left, lands in the previous leaf, doesn't find the key, and returns None.

Nothing is corrupted — the data is still on disk, in the leaf next door. get just walks to the wrong page. That is the failure mode to be afraid of: a silent 2.3% data loss with no exception and no checksum to catch it.

Question 4

Replace the linear scan with a binary search. What happens to comparisons, to page reads, and to wall-clock time?

Answer

Comparisons drop 4.2×. Page reads don't move at all, and wall-clock time doesn't move outside noise.

=== B. linear scan vs binary search inside a node === linear (as written) 10,000 lookups: 30000 page reads, 750151 comparisons, 0.45s binary search 10,000 lookups: 30000 page reads, 178015 comparisons, 0.40s

Page reads are structural — 3 per lookup at height 3, regardless of how you search within a page — so they were never going to move. The wall clock is the interesting one, and §7.1 repeats it four more times to show that the 0.05s here is jitter: linear ranged 0.43–0.51s and binary 0.43–0.46s, and on one pair binary was slower. 572,136 comparisons removed, zero measurable benefit, because the real cost is Node.unpack decoding 63 structs per page.

The right takeaway is not "binary search is useless" (Postgres uses it) but "in-node search is not where the time goes." Fix the page reads or fix the decode; the comparisons are noise.

Question 5

The ascending index does fewer comparisons per lookup (62.0 average) than the random one (75.1), yet it is the slower index. Why isn't that a contradiction?

Answer

Because it is winning the cheap contest and losing the expensive one, which is the trade the entire structure is built around.

Its nodes are half empty, so a linear scan through one finds its answer sooner: 62.0 comparisons over 4 levels is 15.5 per node, against 75.1 over 3 levels = 25.0 per node. That is exactly the 50.8%-vs-69.8% fill difference showing up in the scan length.

But it pays for that with a fourth page fetch on every single lookup. §6.5's break-even says a page read has to be worth more than 4.3 comparisons for the B+tree to beat a BST at all; here the ascending index is spending one whole extra page read to save 13 comparisons. On any real storage device that trade is off by three or four orders of magnitude.

Question 6

Your production table has a bigserial primary key, so every insert appends the largest key yet — the ascending case exactly. Does this toy predict your index is 50% full?

Answer

No, and the reason is worth knowing because it is a real optimisation you can observe.

Postgres detects rightmost-page splits and applies fillfactor (default 90) instead of splitting evenly — nbtsplitloc.c says it exists so that "when we are inserting successively increasing keys (consider sequences, timestamps, etc) we will end up with a tree whose pages are about fillfactor% full, instead of the 50% full result that we'd get without this special case." So you should see roughly 90%, not 50.8%. Question 2 measures what that heuristic is worth in this toy: pages 3,222 → 1,637.

You can check your own: pgstatindex('your_index') reports avg_leaf_density. If it is near 50 on a monotonically-inserted index, something else is going on — most likely deletes leaving half-empty pages that never merged (§8), which is the other well-known route to a bloated index and the one REINDEX exists for.

The general lesson survives even though the specific prediction doesn't: the split point is a policy, your workload interacts with it, and "sequential inserts are the easy case" is an assumption worth measuring rather than believing.


10. Further reading

Every link below was fetched and confirmed live when this was written. Three candidates were dropped: postgresql.org/docs/current/btree-implementation.html (404), and the ACM Digital Library and Springer pages for the occupancy results, which return 403 / paywall redirects to an unauthenticated fetch — Yao 1978 and Wright's Acta Informatica analysis are cited above by reference rather than by link for that reason.