AnyLearn
All lessons
Programmingadvanced

B-Tree Indexes

B+ trees are the workhorse of relational databases. Learn why their high fanout and shallow depth match disk perfectly, how point lookups and range scans work, the difference between clustered and secondary indexes, how covering indexes eliminate table lookups, and the hidden write cost of keeping indexes up to date.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

Why B+ trees, not binary trees?

A balanced binary search tree with nn leaves has height log2n\log_2 n. For 1 billion rows that's 30 levels — 30 disk reads per lookup. A B+ tree with fanout ff (keys per internal node) has height logfn\log_f n. With f=200f = 200 (realistic for 8 KB pages and 40-byte keys), the same 1 billion rows need only log200109=4\lceil \log_{200} 10^9 \rceil = 4 levels.

High fanout is everything. One root page fits in RAM permanently. The second level (200 pages) likely stays warm in the buffer pool. For most lookups, only 1–2 disk reads actually touch storage. This is exactly why B+ trees dominate: the fan-out is tuned to the page size, not to pointer widths.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. Why B+ trees, not binary trees?

A balanced binary search tree with nn leaves has height log2n\log_2 n. For 1 billion rows that's 30 levels — 30 disk reads per lookup. A B+ tree with fanout ff (keys per internal node) has height logfn\log_f n. With f=200f = 200 (realistic for 8 KB pages and 40-byte keys), the same 1 billion rows need only log200109=4\lceil \log_{200} 10^9 \rceil = 4 levels.

High fanout is everything. One root page fits in RAM permanently. The second level (200 pages) likely stays warm in the buffer pool. For most lookups, only 1–2 disk reads actually touch storage. This is exactly why B+ trees dominate: the fan-out is tuned to the page size, not to pointer widths.

2. Node structure: internal vs leaf

A B+ tree has two node types, both stored as fixed-size pages:

Internal nodes hold only keys and child pointers — no row data. A node with kk keys has k+1k+1 children. On a 8 KB page with 8-byte keys and 6-byte pointers, you get fanout ~600.

Leaf nodes hold keys plus either row data (clustered index) or row pointers (secondary index). Leaves are linked in a doubly-linked list at the bottom of the tree — this is what makes range scans efficient: find the first key with a binary descent, then follow leaf pointers linearly.

Invariants: every non-root node is at least half full (prevents degenerate trees). Splits propagate upward; merges propagate downward on deletion. The tree height almost never changes in practice — inserts just fill existing pages.

3. Point lookups and range scans

Point lookup (WHERE id = 42): start at root, binary-search each internal node for the target key, follow the matching child pointer, repeat until leaf. O(logfn)O(\log_f n) page reads — typically 3–4 for billion-row tables.

Range scan (WHERE id BETWEEN 100 AND 200): descend to the leaf containing key 100, then walk the linked leaf list until 200. I/O cost = depth + (number of qualifying leaf pages). This linear leaf scan is sequential on disk if the tree was built in key order — a very common case after a CLUSTER or bulk load.

-- Postgres: force index use and inspect cost
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE order_id BETWEEN 100 AND 200;
-- Look for: Index Scan, Buffers: shared hit=N read=M

4. Clustered vs secondary indexes

Clustered (primary) index: leaf nodes contain the actual row data. The table is stored sorted by the index key. InnoDB always clusters on the primary key. Postgres heap tables are not clustered by default (CLUSTER orders USING orders_pkey physically reorders, but it doesn't stay sorted on writes).

Secondary index: leaf nodes contain the index key plus a pointer to the row (a (page_id, slot_id) or the primary key, depending on the engine). A lookup hits the secondary index then does a second lookup — the table heap fetch or primary key lookup.

PropertyClusteredSecondary
Row lookup1 index read (leaf = row)index read + heap fetch
Range scansequential I/Opotentially random I/O
Count per table1 (the table IS the index)unlimited
Write costreorder on PK changeupdate all secondary indexes

5. Covering indexes and index-only scans

A covering index includes all columns a query needs — the query is satisfied entirely from the index without touching the table. This eliminates the heap fetch, which is the expensive part of a secondary index lookup (especially when rows are scattered randomly across pages).

-- Without covering index: index scan + heap fetch per row
CREATE INDEX idx_orders_customer ON orders (customer_id);
SELECT customer_id, total FROM orders WHERE customer_id = 7;
-- total requires a heap fetch for each matching row

-- With covering index: index-only scan
CREATE INDEX idx_orders_customer_total ON orders (customer_id, total);
SELECT customer_id, total FROM orders WHERE customer_id = 7;
-- EXPLAIN shows: Index Only Scan — zero heap fetches

In Postgres, an index-only scan still checks the visibility map to confirm whether the index tuple is visible to the current transaction — a small overhead, but far cheaper than a heap fetch.

6. Write amplification on inserts and updates

Indexes are not free. Every INSERT, UPDATE, or DELETE must maintain all indexes on the table. A table with 5 indexes turns one logical write into at least 6 physical page updates — plus potential page splits that cascade up the tree.

Page splits are the worst case: when a leaf is full, it splits into two half-full leaves, and the parent needs a new separator key. If the parent is also full, the split propagates. Each split dirtifies 2–3 pages and writes WAL records for all of them.

Mitigation strategies:

  • Monotonically increasing keys (UUIDs v7, sequences): inserts always go to the rightmost leaf — no splits in the middle of the tree, excellent sequential I/O.
  • Random UUIDs (v4): inserts scatter across the tree, causing frequent mid-tree splits and fragmenting leaf pages. Write amplification can be 5–10x higher than sequential keys.
  • Fill factor: CREATE INDEX ... WITH (fillfactor=70) leaves 30% of each page empty for in-place updates, deferring splits.

7. B+ tree structure and scan paths

Internal nodes route lookups; leaf nodes store data and are linked for range scans:

flowchart TD
  R["Root: internal node [50, 200]"]
  I1["Internal [10, 30]"]
  I2["Internal [100, 150]"]
  L1["Leaf [1..9]"]
  L2["Leaf [10..29]"]
  L3["Leaf [30..49]"]
  L4["Leaf [50..99]"]
  L5["Leaf [100..149]"]
  L6["Leaf [150..200+]"]
  R --> I1
  R --> I2
  I1 --> L1
  I1 --> L2
  I1 --> L3
  I2 --> L4
  I2 --> L5
  I2 --> L6
  L1 --> L2
  L2 --> L3
  L3 --> L4
  L4 --> L5
  L5 --> L6

8. Practical index design rules

A few opinionated rules that hold across Postgres, MySQL, and SQL Server:

  • Index selectivity matters: an index on a boolean column (2 distinct values) is almost never used by the optimizer — it's cheaper to scan the table.
  • Leading column rule: a composite index (a, b, c) supports filters on a, (a, b), or (a, b, c) — but not on b or c alone. Design the leading column for your most common filter.
  • Avoid over-indexing: each index adds ~10–30% write overhead on inserts. Most tables need 3–5 indexes maximum.
  • EXPLAIN ANALYZE is ground truth: the optimizer's cost estimates can be wrong; always verify with real execution plans and BUFFERS output before and after adding an index.

9. When the optimizer skips your index

Indexes exist but the planner chooses a sequential scan in these common situations:

SituationWhy planner skips index
Low selectivity (WHERE active = true, 90% rows)Sequential scan reads fewer total pages
Function on indexed column (WHERE LOWER(email) = ...)Index on email can't match LOWER(email) — use a functional index
Stale statisticsANALYZE updates pg_statistic; planner uses wrong row estimates
Very small tableCost model prefers seq scan below ~8 pages
LIKE '%suffix' patternLeading wildcard prevents left-to-right B-tree traversal

Fix: run ANALYZE, create functional indexes, or hint the planner with SET enable_seqscan = off (debugging only — never in production).

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why do B+ trees use such high fanout (hundreds of children per node) compared to binary trees?
    • Higher fanout reduces the size of the index on disk.
    • Higher fanout reduces tree height, minimizing the number of disk page reads per lookup.
    • Higher fanout makes range scans faster by reducing the number of leaf nodes.
    • Higher fanout is required because disk blocks cannot hold binary tree nodes.
  2. What makes B+ tree range scans efficient compared to a binary search tree?
    • B+ tree internal nodes store row data, reducing lookups.
    • B+ tree leaf nodes are linked in a list, enabling sequential traversal after the first descent.
    • B+ trees store all data sorted in a single flat array.
    • B+ trees cache all internal nodes in the buffer pool permanently.
  3. You add a secondary index `(customer_id)` to an orders table, then run `SELECT customer_id, total FROM orders WHERE customer_id = 7`. What additional step does the engine need that an index-only scan avoids?
    • Sorting the result set by customer_id.
    • A heap fetch to retrieve the `total` column not stored in the index.
    • Locking the customer_id leaf page during the scan.
    • Rebuilding statistics for the customer_id column.
  4. Why do random UUID v4 primary keys cause worse write performance than sequential keys?
    • UUIDs are 16 bytes, making internal nodes store fewer keys.
    • Random UUIDs scatter inserts across the entire tree, causing frequent mid-tree page splits and fragmented leaf pages.
    • The database must sort UUIDs before inserting them, adding CPU overhead.
    • UUIDs cannot be used as clustered index keys in InnoDB.
  5. A query `SELECT * FROM users WHERE LOWER(email) = 'alice@example.com'` ignores an existing index on `email`. What is the correct fix?
    • Run VACUUM to reclaim dead tuples in the email index.
    • Create a functional index on LOWER(email).
    • Increase the fillfactor of the existing email index.
    • Set enable_seqscan = off in postgresql.conf permanently.

Related lessons