B-tree Indexes
// the problem
You have ten million rows. Postgres finds one of them in well under a millisecond — without reading them all. The answer is a balanced tree of pointers called a B-tree. First build one by hand below; then watch a real Postgres planner switch to using one, live in your browser.
Insert a few keys. Each node holds up to three keys. When a fourth would arrive, the node is full — so it splits: the middle key moves up to the parent and the node becomes two. Splitting is what keeps the tree balanced, so every leaf stays the same distance from the root. (This is bottom-up splitting — the node splits only when it actually overflows, exactly as Postgres does it.)
insert keys → watch nodes split · search a key → trace the descent
Now switch to search. Type a key and watch the lookup descend: at each node Postgres compares your key against the node's keys and follows a single pointer down. A tree only three or four levels tall can index millions of rows — so a search touches a handful of nodes instead of scanning the whole table. That gap is the entire reason indexes exist: a B-tree turns a linear scan into a logarithmic descent.
Why "B-tree" and not a binary tree? (do the math)
A binary tree branches two ways per node, so its height is log₂(N). For
10 million rows that's ~24 levels — and since each node could be a separate
page, that's up to 24 random page reads to find one row.
A Postgres B-tree node is one 8 KB page (the same pages from the Storage
lesson), and a leaf entry — a (key, ctid) pair — is only ~16 bytes. So a single
page holds hundreds of keys and the tree branches hundreds of ways per level.
The height is log₍fanout₎(N): with a fanout of ~500, ten million rows fit in
log₅₀₀(10,000,000) ≈ 3 levels. That's the whole game — the same lookup,
24 page reads vs 3, purely because the tree is short and wide instead of tall and
thin. (You'll measure a real index's size and depth a few cells down.)
From the toy to a real index
Let's leave the simulation and use an actual Postgres. This creates a 50,000-row table with no index yet:
Ask the planner how it would find one row. EXPLAIN shows the plan without
running it — read the cost and the scan type:
A Seq Scan: Postgres reads every page of the table (cost ≈ 944) because it has no faster option. Now give it one and ask again:
The plan flips to an Index Scan with a cost of ~8 — a hundredfold cheaper.
Those cost numbers aren't arbitrary: the Seq Scan's ~944 is roughly read all
~368 heap pages + evaluate the filter on all 50,000 rows; the Index Scan's ~8 is
descend a two-level tree + fetch one heap page. Add BUFFERS to see the
descent in physical terms — one row out of 50,000 in a handful of page
touches:
The Buffers: shared hit=… read=… line adds up to just a few 8 KB pages —
the root, a leaf, and the one heap page holding the row. Now measure the index
itself: over 50,000 rows it's only ~140 pages (a root plus a layer of leaves —
two levels), and about a third the size of the table it indexes:
The planner picks the cheapest path — not always the index
An index isn't always the right tool, and Postgres decides by estimated
cost, not by whether an index merely exists. Add an index on country and
compare three queries:
Three predicates, three plans: a tiny result uses a plain Index Scan; a chunky ~25% slice here uses a Bitmap Heap Scan (collect matching row locations from the index into a page-ordered bitmap, then read the heap in physical order); a column with no index falls back to Seq Scan. The exact choice for a mid-range slice depends on cost settings — the planner does the arithmetic every time.
// why it matters · why a bitmap scan in the middle
For a few rows, jumping around the heap via the index is cheapest. For a big fraction of the table, reading it sequentially wins. The bitmap scan is the in-between: use the index to find which pages have matches, then read those pages in order — fewer random I/Os than a plain index scan, less waste than a full seq scan.
EXPLAIN only estimates. Add ANALYZE to actually run it and see real timings
and buffer reads (shared hit = cache, read = from disk):
What's actually stored in the index?
A B-tree leaf doesn't store rows — it stores (key, ctid) pairs. The
key orders the tree; the ctid is the row's address back in the heap (from the
Storage lesson). A lookup descends to the leaf, reads the ctid, then fetches that
one heap page. These are the exact pairs an index on id holds:
Crucially, the leaf pages are chained together in key order — a doubly-linked list running along the bottom of the tree. That has two big consequences beyond point lookups:
The index is already sorted, so ordering is free. ORDER BY id doesn't need
a sort step — Postgres just walks the leaf chain in order. Notice the plan has an
Index … Scan and no Sort node:
Min and max are instant. The smallest key is the first leaf entry, the
largest is the last — so MAX(id) is a single walk to the end of the chain
(Index Only Scan Backward), not a scan of 50,000 rows:
And a range like WHERE id BETWEEN 100 AND 110 descends once to 100, then
walks the leaf chain until it passes 110 — reading a contiguous run of entries
instead of the whole table.
Index-only scans
If a query needs only columns the index already contains, Postgres can answer straight from the index and skip the heap entirely — an Index Only Scan:
This is where the Storage lesson's visibility map pays off: the index has
the key but not the row's visibility, so Postgres still must confirm the row
is visible — unless the _vm fork says the whole page is all-visible, letting it
skip the heap touch. Covering more columns with INCLUDE makes index-only scans
possible for more queries.
Multi-column indexes read left to right
An index on (country, id) sorts by country first, then by id within each
country. Picture the leaf order: all the US entries in id-order, then all the
GB entries in id-order, and so on. That layout is ideal for a leftmost
prefix — WHERE country = 'US', or WHERE country = 'US' AND id = 100 — where
Postgres descends straight to the right spot.
But what about WHERE id = 100 alone, with no country? The matching ids are
scattered — one inside each country's block — so there's no single place to
descend to. Run both and compare the cost:
Both use the index — but the trailing-column query costs ~3× more. Modern
Postgres (v18) uses a skip scan: it skips through each distinct country
value and does a small search for id = 100 inside each block. Clever, but it's
several searches instead of one clean descent, so it's far slower than a leading
prefix — and slower than a dedicated (id) index would be. The old rule of thumb
("a composite can't help a non-leading column") is really about efficiency: it
can, just not well.
// note · order the columns by how you query
Put the columns you filter on by equality first, then range/sort columns, and
lead with what your queries actually constrain. A well-ordered composite index
serves a whole family of queries ((a), (a,b), (a,b,c)) from one structure —
often replacing several single-column indexes and their write cost.
Indexes aren't free
An index makes reads faster by making writes slower and the database bigger — you
saw the id index alone was about a third the size of the table. Every index
is a second structure Postgres must keep in sync: a single INSERT into a table
with three indexes is really four writes (the heap tuple plus a new entry in
each index), and each of those index writes may split a page. That's write
amplification — the reason "just add an index" isn't free, and why an
over-indexed table can be slower to write than to read. Indexes also bloat
from updates and deletes and need their own maintenance (REINDEX).
// gotcha · HOT updates dodge index writes
If an UPDATE changes no indexed column and the new tuple fits on the same
page, Postgres does a Heap-Only Tuple (HOT) update: it chains the new version
on the page and skips touching the indexes entirely. It's a major reason to
keep frequently-updated columns out of indexes and to leave some room per page
(fillfactor).
Beyond B-tree
// note · the right index for the data
B-tree is the default and handles equality and range on ordered types. Postgres
also ships Hash (equality only), GIN (multi-value columns: arrays,
jsonb, full-text), GiST (geometric / nearest-neighbour), SP-GiST, and
BRIN (huge, naturally-ordered tables — tiny indexes that store per-block
ranges). Same CREATE INDEX, different USING method.
Your turn
The users table (from the first cell) has 50,000 rows.
With the id index dropped, this lookup is a Seq Scan. Add an index so the same query becomes an Index Scan, and prove it with EXPLAIN.
How many users are in country 'US'? Return a single count.
// what you now understand
- 01Short and wide beats tall and thin: an 8 KB node holds hundreds of (key, ctid) entries, so millions of rows fit in 3–4 levels. A lookup is a handful of page reads — measured, one row out of 50,000 in ~3 buffers — vs ~24 for a binary tree.
- 02Each index leaf stores (key, ctid) pairs; the descent ends at a ctid, then one heap page read fetches the row.
- 03Leaf pages are chained in key order, so the index gives sorted output for free: ORDER BY needs no Sort node, and MIN/MAX/ranges walk the leaf chain instead of scanning.
- 04The planner is cost-based: a tiny result → Index Scan, a medium slice → Bitmap Heap Scan, no useful index → Seq Scan.
- 05An Index Only Scan answers from the index alone, helped by the visibility map.
- 06Composite indexes sort left-to-right: a leading prefix descends efficiently; a trailing column can only be served by a costlier skip scan (~3× here). Order the columns to match your queries.
- 07Indexes aren't free: each is ~a third+ of the table's size and adds a write to every INSERT/UPDATE/DELETE (write amplification). HOT updates dodge index writes when no indexed column changes.
- 08B-tree is the default; GIN/GiST/BRIN/Hash exist for other data shapes.
// self-test
A B-tree is 3 levels deep. Roughly how many node reads to find one key?
// self-test
There's an index on country, but `WHERE country = 'US'` matches ~25% of rows. What is the planner most likely to do?
// go deeper
- Indexes (official docs) — types, multicolumn, index-only, INCLUDE
- B-Tree internals — implementation, deduplication, operator classes
- Using EXPLAIN — reading plans, costs, ANALYZE/BUFFERS
- nbtree README — the real Lehman & Yao B-tree in the source