B-tree indexes: how a lookup really works
An index is not the database magically getting faster — it's a specific tree structure that turns reading every page into walking four pages. This lesson builds a B-tree from the problem it solves, walks a single lookup root-to-leaf-to-heap, and then explains the rules that decide whether your index gets used at all — the leftmost-prefix rule for composite indexes, index-only scans, and the several ways the planner correctly ignores an index you were sure it would use.
B-tree indexes: how a lookup really works
Last lesson ended on a smell: a query read 1091 pages to return 94 rows, throwing away 119,906 along the way. That's a sequential scan — Postgres read the entire heap because it had no faster way to find those 94 rows. You already know the fix is "add an index," the way you know useMemo makes React faster: true, but useless until you know what it actually builds. An index is not a vague speed boost. It's a concrete on-disk data structure — almost always a B-tree — and once you can picture the tree, you can predict exactly when it helps, when the planner ignores it, and why every index you add makes writes slower.
The problem, stated precisely
You want one row (or a small range of rows) out of a million, keyed on some column, without reading a million rows. A sequential scan is O(n) in pages — fine for 1000 rows, ruinous for 100 million. What you want is something closer to how you find a word in a physical dictionary: you don't read every page; you open to the middle, see you've gone too far, jump back, and converge in a handful of steps. That's a tree search, and it's O(log n). The whole job of a B-tree index is to be that dictionary — a sorted structure you can binary-search from the top instead of scanning from the front.
What a B-tree actually is
The "B-tree" Postgres builds is, in practice, a B+ tree, and two properties of it matter more than the name:
- All the actual keys-plus-pointers-to-rows live in the leaf level. The internal (upper) levels hold only separator keys — signposts that say "keys less than X are down this branch, keys ≥ X are down that one." You never find your answer in an internal node; you use internal nodes only to navigate down to the correct leaf.
- The leaves are linked to their siblings in sorted order, like a doubly-linked list. Once a lookup lands on the right leaf, a range query (
created_at BETWEEN … AND …,ORDER BY … LIMIT) can just walk sideways leaf-to-leaf without ever going back up the tree.
Each node of the tree is — of course — an 8KB page, the same unit as everything in the last lesson. And here's the number that makes B-trees feel like magic: because each key is small (an integer, a ULID, a short string) and a page is 8KB, hundreds of keys fit in a single node. That's the tree's fan-out — how many children each node points to. With a fan-out of, say, a few hundred, the arithmetic is startling:
level 0 (root): 1 page, ~hundreds of separator keys
level 1 (internal): ~hundreds of pages
level 2 (internal): ~hundreds² of pages
level 3 (leaves): ~hundreds³ of pages → billions of keys
fan-out 300: 300³ ≈ 27 million, 300⁴ ≈ 8 billion keysA tree only 3 to 4 levels deep indexes a table with billions of rows. That is the whole payoff: any single row is reachable in 3–4 page reads instead of a million. And the top levels are so small and so frequently touched that they live permanently in the buffer pool — so in practice only the last hop or two ever touches disk.
[ root page ]
| k<M | k≥M |
/ \
[ internal ] [ internal ]
| .. | .. | | .. | .. |
/ \ / \
[leaf]→→→[leaf]→→→→→→[leaf]→→→[leaf] ← siblings linked, sorted
| | | |
(TID) (TID) (TID) (TID) ← each leaf entry points into the heapOne lookup, step by step
Say you run SELECT * FROM messages WHERE id = '01H8X…' with a B-tree index on id. Here's every hop:
- Read the root page. Binary-search its separator keys to decide which child branch
'01H8X…'falls into. Follow that pointer. (Root is almost always a buffer-pool hit.) - Read the internal page you were sent to. Binary-search its separators, pick the next branch down.
- Read the leaf page. Binary-search it to find the exact index entry for
'01H8X…'. That entry contains the key and a TID — remember from D1, the(page number, slot number)address of the actual row in the heap. - Heap fetch. Now go read that heap page and pull the tuple at that slot.
Steps 1–3 walked the index; step 4 went to the table itself. And here's the part people miss: step 4 is usually the expensive one. The index pages are few, hot, sorted, and cached. The heap page could be anywhere — and if your query matches many rows scattered across the table, each match is a separate random heap fetch to a different page. Recall the sequential-vs-random asymmetry from D1: an index turns one big sequential scan into many small random reads. That's a fantastic trade when you're fetching a handful of rows, and a terrible one when you're fetching a third of the table — which is the seed of "why the planner ignored my index," below.
Composite indexes and the leftmost-prefix rule
You can index more than one column: CREATE INDEX ON messages (group_id, created_at). This does not build two indexes. It builds one tree whose keys are the pair (group_id, created_at), sorted first by group_id, and then by created_at within each group_id — exactly like a phone book sorted by last name, then first name.
That sort order is everything, and it gives the leftmost-prefix rule: an index on (A, B) can efficiently serve a query that filters on A, or on A AND B, but not a query that filters only on B. Ask the phone book for everyone named "Priya" (any last name) and its last-name-first ordering is useless — Priyas are scattered across every letter. Same tree, same data, but the query has to match a prefix of the sort key or the tree can't narrow the search.
| Query filter | Can it use index on (group_id, created_at)? |
|---|---|
group_id = X | Yes — leftmost column, narrows straight down the tree |
group_id = X AND created_at > T | Yes — full prefix, the ideal case |
group_id = X ORDER BY created_at | Yes — and the sort is free, rows already emerge in order |
created_at > T (no group_id) | No — created_at isn't a prefix; typically a seq scan |
That third row is a quiet superpower: because the leaves are physically sorted by created_at within a group_id and linked to their siblings, WHERE group_id = X ORDER BY created_at LIMIT 50 doesn't sort anything at all — it finds the start leaf and walks sideways 50 entries. This is why the column order in a composite index is a real design decision, not a formality: put the column you filter for equality first, the column you range-scan or sort by second.
Index-only scans (and a catch to remember)
Sometimes you can skip the expensive heap fetch entirely. If an index contains every column the query needs, Postgres can answer straight from the leaf entries and never touch the heap — an index-only scan. An index on (group_id, created_at) serving SELECT created_at FROM messages WHERE group_id = X needs nothing the index doesn't already hold. Postgres also lets you bolt extra payload columns onto an index purely for this purpose: CREATE INDEX … (group_id) INCLUDE (created_at) — a covering index, carrying columns it doesn't sort by, just so lookups don't have to visit the table.
But there's a catch that ties straight back to D1's hidden tuple header, and forward to D4: the index entry does not record whether a row version is still visible to your transaction — that lives in the heap tuple's xmin/xmax. So Postgres keeps a visibility map marking which heap pages are "all-visible" (every row on them is visible to everyone). An index-only scan works only for pages the visibility map has flagged clean; for the rest it must fall back and visit the heap after all. A table that's just been heavily updated has a stale visibility map, so your index-only scan quietly does heap fetches anyway until VACUUM catches up. File that away — it's a thread we pick up in the MVCC lesson.
When the planner ignores your index
The most confusing moment in database work is adding the perfect index and watching EXPLAIN do a sequential scan anyway. Usually the planner is right. The common reasons:
- Low selectivity. If your
WHEREmatches a large fraction of the table (say a booleanis_active = truethat's true for 80% of rows), the index would mean thousands of random heap fetches — and a sequential scan of the whole table is cheaper. Indexes win when they exclude most of the table; they lose when they don't. This is the random-vs-sequential trade-off from D1 making a decision. - A function wraps the column. An index on
created_atsorts bycreated_at, not bydate(created_at). SoWHERE date(created_at) = '2026-07-09'can't use it — the tree isn't sorted the way the expression asks. (The fix is an expression index ondate(created_at), which builds a tree sorted that way.) - A type mismatch. Compare a
bigintcolumn to anumericvalue —WHERE id = 12345.0, or a driver that binds your parameter asnumeric— and Postgres resolves it asnumeric = numeric, which casts the column side to make the comparison. That wraps the column in a cast and defeats the index for exactly the same reason as the function case above. (Postgres won't silently coerce across unrelated types liketextvs integer — that comparison just errors — but within the numeric family the quiet cast is real, and drivers do it to you.) - Stale statistics. The planner chooses using sampled statistics about how many rows a filter will match. If those are stale (a big data change with no
ANALYZE), it can badly misjudge selectivity and pick the wrong plan.ANALYZErefreshes them.
The tool for all of these is the same one from D1: EXPLAIN (ANALYZE, BUFFERS). It tells you the plan the planner chose and the rows it actually matched, so you can see whether it skipped your index because it was smart or because the query defeated the index.
Go deeper
- Use The Index, Luke! — Markus Winand (free online book) — The single best resource on indexes for application developers — the leftmost-prefix rule, index-only scans, and "why is my index not used" explained with runnable SQL across every major database.
- PostgreSQL docs — "Indexes" — The authoritative reference on Postgres index types, multicolumn and covering indexes, expression indexes, and index-only scans, straight from the source.
- Use The Index, Luke! — "Index-Only Scan: Avoiding Table Access" — The clearest walkthrough of when a covering index lets you skip the expensive heap fetch entirely, and what it costs you.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- A B-tree over a billion rows is only 3–4 levels deep. Which property of the tree makes it that shallow, and why does that same property mean a lookup almost never does 3–4 disk reads in practice?
- Walk a single-row lookup from root to the returned tuple. Which of those hops is usually the expensive one, and why is it the heap fetch rather than any of the index-page reads?
- You have an index on (group_id, created_at). Explain why WHERE group_id = X ORDER BY created_at LIMIT 50 does no sorting at all, while WHERE created_at > T (no group_id) can't use the index — even though both mention created_at.
- An index-only scan on a freshly bulk-updated table mysteriously still does heap fetches. What structure decides whether the heap can be skipped, why does an index entry alone not carry enough information, and what fixes it?
- You add exactly the index a slow query needs and EXPLAIN still shows a sequential scan. Give two distinct reasons the planner might be correct to ignore it (one about selectivity, one about a function wrapping the column), and name the flag in EXPLAIN output that hints at a selectivity problem.
- A table has ten indexes and its INSERTs are slow. Explain write amplification concretely — what work does one INSERT actually trigger — and why a single well-ordered composite index can be strictly better than several single-column ones.