Storage And Indexing
B+ Tree Indexes
The DSA Notes presented the BST as the standard ordered structure. On disk it is close to useless, and the reason is Chapter 1's cost model.
JrCodex·8 min read
Jr Codex DBMS Notes
Level: Intermediate Prerequisites: Chapter 1: How a Database Stores Data; DSA Notes, Module 7, Chapter 3 Time to complete: ~25 minutes
Table of Contents
- Why Not a Binary Search Tree
- The B+ Tree Shape
- Why It Is So Shallow
- Searching
- Range Scans and the Leaf Chain
- Insertion and Splitting
- What This Costs You
- Summary & Next Steps
1. Why Not a Binary Search Tree
The DSA Notes presented the BST as the standard ordered structure. On disk it is close to useless, and the reason is Chapter 1's cost model.
The Arithmetic
─────────────────────────────────────────
10,000,000 rows.
BALANCED BST
height = log₂(10,000,000) ≈ 24
Each node is small and lives on its own page.
A lookup = 24 RANDOM PAGE READS.
24 × 100µs ≈ 2.4 ms
B+ TREE with 400 keys per node
height = log₄₀₀(10,000,000) ≈ 3
A lookup = 3 page reads.
3 × 100µs ≈ 0.3 ms
Eight times fewer reads, from the same O(log n)
— because the LOGARITHM BASE changed.
─────────────────────────────────────────
The Insight
─────────────────────────────────────────
A page read costs the same whether you use 16
bytes of it or 8,192.
A binary node wastes almost the entire page. So
make the node AS BIG AS A PAGE and give it
hundreds of children.
Height collapses. That is the whole idea.
─────────────────────────────────────────
2. The B+ Tree Shape
Structure
─────────────────────────────────────────
┌───────────────┐
ROOT │ 30 | 60 │ internal:
└───┬───┬───┬───┘ KEYS + child
│ │ │ pointers only
┌─────────┘ │ └─────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 10 | 20 │ │ 40 | 50 │ │ 70 | 80 │ internal
└──┬───┬──┘ └──┬───┬──┘ └──┬───┬──┘
▼ ▼ ▼ ▼ ▼ ▼
┌────┐┌────┐ ┌────┐┌────┐ ┌────┐┌────┐
│10→ ││20→ │→ │40→ ││50→ │→ │70→ ││80→ │ LEAVES:
│data││data│ │data││data│ │data││data│ keys + row
└────┘└────┘ └────┘└────┘ └────┘└────┘ pointers
└──────┴───────┴──────┴───────┴─────┘
LINKED LIST across all leaves
─────────────────────────────────────────
The Three Defining Properties
─────────────────────────────────────────
1. ALL DATA POINTERS ARE IN THE LEAVES.
Internal nodes hold only keys for navigation.
So internal nodes fit MORE keys, so the tree
is shallower. (This is the "+" in B+ tree; a
plain B-tree stores data in internal nodes
too.)
2. LEAVES ARE LINKED.
A doubly-linked list in key order. This is
what makes range scans fast (Section 5).
3. PERFECTLY BALANCED.
Every leaf is at the same depth, always. Every
lookup costs exactly the height — no worst
case, unlike an unbalanced BST.
─────────────────────────────────────────
3. Why It Is So Shallow
PAGE_SIZE = 8192
KEY_BYTES = 8 # a BIGINT key
PTR_BYTES = 8 # a child pointer
HEADER = 64
fanout = (PAGE_SIZE - HEADER) // (KEY_BYTES + PTR_BYTES)
print(f"fanout ≈ {fanout} children per node") # ≈ 508
for height in range(1, 5):
print(f"height {height}: up to {fanout ** height:,} keys")
# height 1: 508
# height 2: 258,064
# height 3: 131,096,512
# height 4: 66,597,028,096Read That Table Again
─────────────────────────────────────────
A three-level B+ tree indexes 131 MILLION rows.
So almost any lookup in almost any real table is
THREE page reads.
And in practice fewer: the root is always cached,
and the level below it usually is too (Chapter
5). The typical cost is ONE actual disk read.
─────────────────────────────────────────
The Practical Corollary
─────────────────────────────────────────
Index key SIZE matters, because it sets the
fanout.
8-byte integer key ──► fanout ~508
60-byte text key ──► fanout ~120
The text index is deeper and every node holds
fewer keys, so the whole index is larger and
colder. This is a concrete reason to prefer
integer surrogate keys (Module 2, Chapter 2).
─────────────────────────────────────────
4. Searching
class Node:
def __init__(self, leaf=False):
self.keys, self.children, self.leaf, self.next = [], [], leaf, None
def search(node, key):
"""Descend from root to leaf. One page read per level."""
while not node.leaf:
i = 0
while i < len(node.keys) and key >= node.keys[i]: # binary search in practice
i += 1
node = node.children[i] # ← ONE page read
for k, ptr in zip(node.keys, node.children): # now in the leaf
if k == key:
return ptr # the row pointer
return NoneCost, Precisely
─────────────────────────────────────────
PAGE READS the height. 3 or 4, always.
COMPARISONS log₂(fanout) per node via binary
search within the page — pure CPU,
essentially free.
The whole design trades more in-memory
comparisons for fewer disk reads, which is
exactly the right trade under Chapter 1's cost
model.
─────────────────────────────────────────
5. Range Scans and the Leaf Chain
The property that makes B+ trees right for databases specifically.
SELECT * FROM students WHERE marks BETWEEN 70 AND 90;How It Runs
─────────────────────────────────────────
1. Descend to the leaf containing 70.
3 page reads.
2. Read entries rightwards along the LEAF LINKED
LIST until a key exceeds 90.
SEQUENTIAL reads — cheap.
A hash index cannot do this at all: it can find
70 and 90, and has no idea what lies between
(Chapter 3).
─────────────────────────────────────────
Everything a Sorted Structure Gives You
─────────────────────────────────────────
WHERE x BETWEEN a AND b range
WHERE x > a open range
ORDER BY x free — walk the
leaves; NO SORT STEP
MIN(x) / MAX(x) leftmost / rightmost
leaf. O(height).
WHERE x LIKE 'abc%' a prefix IS a range
GROUP BY x pre-sorted input
This is why B+ trees are the default index in
every relational database: one structure serves
equality, ranges, sorting and grouping.
─────────────────────────────────────────
6. Insertion and Splitting
The Algorithm
─────────────────────────────────────────
1. Descend to the correct leaf.
2. Room? Insert in key order. Done.
3. Full? SPLIT:
- divide entries between two leaves
- push the middle key UP to the parent
- if the parent is now full, split it too
- if the ROOT splits, a new root is created
and the tree grows one level
The tree grows at the ROOT, never at the leaves.
That is why it stays perfectly balanced with no
rebalancing pass.
─────────────────────────────────────────
Sequential vs Random Insert Keys
─────────────────────────────────────────
SEQUENTIAL (auto-increment id)
Every insert lands in the RIGHTMOST leaf.
Splits are cheap, pages fill ~100%, and the hot
page stays cached.
── the good case
RANDOM (UUID v4)
Inserts scatter across the whole tree.
Every insert may touch a cold page. Splits leave
pages ~50% full, so the index is roughly TWICE
the size.
── measurably worse, on both writes and reads
Fix if you need UUIDs: use a time-ordered variant
(UUIDv7, ULID) so inserts are sequential again.
This is a real and common production problem.
─────────────────────────────────────────
7. What This Costs You
An index is not free, and the costs are the reason not to index everything.
The Four Costs
─────────────────────────────────────────
WRITE AMPLIFICATION
Every INSERT, UPDATE of an indexed column, and
DELETE must update EVERY affected index.
Five indexes ──► roughly six writes per insert.
SPACE
Often 10-30% of the table per index. Several
indexes can exceed the table's own size.
MEMORY PRESSURE
Index pages compete with table pages for the
buffer pool (Chapter 5). Unused indexes evict
useful data.
OPTIMISER COST
More indexes means more candidate plans to
consider on every query.
─────────────────────────────────────────
-- Find indexes nobody uses (PostgreSQL) — usually a surprising list.
SELECT relname AS table, indexrelname AS index, idx_scan AS times_used,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;The Balance
─────────────────────────────────────────
READ-HEAVY index generously; reads dominate
WRITE-HEAVY index sparingly; every index taxes
every write
Chapter 4 turns this into a procedure.
─────────────────────────────────────────
8. Summary & Next Steps
Key Takeaways
- A binary search tree wastes almost an entire page per node; a B+ tree makes the node page-sized, which changes the logarithm's base and collapses the height to three or four.
- Data pointers live only in leaves, so internal nodes pack more keys and the tree stays shallow; leaves are linked, which is what makes range scans and free ordering possible.
- Index key size sets the fanout, so a wide text key produces a deeper, larger, colder index than an integer one.
- Random insert keys such as UUIDv4 scatter writes and leave pages half full, roughly doubling index size — time-ordered identifiers fix it.
Concept Check
- Both a BST and a B+ tree are O(log n). Why is one usable on disk and the other not?
- Which structural property makes
ORDER BY indexed_columnfree, and which index type lacks it? - Why does switching a primary key from auto-increment to UUIDv4 slow down both writes and reads?
Next Chapter
→ Chapter 3: Hash and Specialised Indexes
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to DBMS Index