postgres://internals

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.)

fig.01 · b-treeorder 4 · max 3 keys/node
keys
0
nodes
1
height
1
reads
result
readout

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:

sql · live postgresrun me first
⌘/ctrl + enter

Ask the planner how it would find one row. EXPLAIN shows the plan without running it — read the cost and the scan type:

sql · live postgresno index yet → Seq Scan
⌘/ctrl + enter

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:

sql · live postgresadd the index, then re-explain
⌘/ctrl + enter

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:

sql · live postgrescount the pages the descent actually reads
⌘/ctrl + enter

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:

sql · live postgresthe index is small and shallow
⌘/ctrl + enter

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:

sql · live postgresthree predicates, three different plans
⌘/ctrl + enter

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):

sql · live postgreseditable — run it
⌘/ctrl + enter

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:

sql · live postgreseditable — run it
⌘/ctrl + enter

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:

sql · live postgresORDER BY with no Sort — the leaves are already in order
⌘/ctrl + enter

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:

sql · live postgreseditable — run it
⌘/ctrl + enter

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:

sql · live postgresselecting only the indexed column
⌘/ctrl + enter

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 prefixWHERE 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:

sql · live postgresleading prefix vs trailing column, same index
⌘/ctrl + enter

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.

// challengewrite it yourself

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.

⌘/ctrl + enter
// challengewrite it yourself

How many users are in country 'US'? Return a single count.

⌘/ctrl + enter

// 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

nextQuery Planner