17 The B-tree does the heavy lifting
Most slow queries aren’t doing anything hard. They’re reading the whole table to find a handful of rows, every time, because nobody told Postgres where to look.
Here’s one, run whenever someone opens a project page:
It read 35,249 pages to return 20 rows: every one of the table’s 35,173 pages (all 275 MB of events), plus a few dozen for planning and the sort, every time the page loads. On our laptop, with the table in memory, that took 26 ms, which is why nobody noticed. On a production box with a cold cache and fifty people opening dashboards at once, it’s the query that takes the database down.
Prove it · lab 16-btree
After the right create index, it reads 23 pages. That’s the cheapest speedup you’ll ever buy, and this chapter is about buying it on purpose while refusing the indexes that only cost you. In every plan, watch the Buffers: line, which counts 8 KB pages. Pages are the evidence; milliseconds are weather (Buffers are the evidence).
A B-tree is a shallow tree of pages
Think of a library card catalog: the cabinet sign picks the drawer, the tabs pick the card, and the card names the shelf. You never read the whole catalog. A B-tree is that catalog, built from 8 KB pages, and the shelf is the heap: the table itself, an unordered pile of pages (Everything is a page). Here is the primary key of events:
The leaves hold every key in order, each with a TID, the row’s (page, item) address in the heap (its ctid), and link to their neighbors so range scans walk sideways. The pages above only say which child to visit next.
The shape is the whole trick. Each internal page points at roughly 300 children, so three levels hold about 30 million keys and four about 10 billion. Grow the table three-hundredfold and a lookup costs one more page. In A query is pages and round trips, a primary key lookup read four: three in the tree, one in the heap. Hold on to that:
An index lookup costs the depth of the tree plus one heap page per matching row.
The tree’s share is three or four pages and barely grows. The heap pages are where the money goes, and the rest of this chapter is about spending fewer of them.
There’s no clustered index: every index, the primary key included, points into an unordered heap (The table is the primary key in InnoDB).
Deep dive: What pageinspect shows inside the primary key
Block 0 of every B-tree is a metapage that records where the root is. pageinspect reads it and summarizes every page (bt_multi_page_stats is PostgreSQL 16+):
Root at block 412, level 2: three levels. Nineteen internal pages point at about 289 leaves each, and 5,465 leaves hold about 367 entries of 16 bytes (an 8-byte key plus an 8-byte header carrying the TID). One slot per leaf is the high key, the page’s upper bound (the first leaf’s is 367, the first id on the next leaf), so 5,465 leaves of 366 rows is the two million.
Prove it · lab 16-btree
The first index: 35,249 pages to 91
Start with the obvious index, on the where column:
From 35,249 pages to 91. Read it bottom up:
- The index: 3 pages to find all 85 of the project’s rows.
- The heap: 85 pages, one per row (
Heap Blocks: exact=85). The events arrived at random times over the year, so each landed on a different page. - The sort: kept the newest 20.
Three pages to find, 85 to fetch. That ratio is normal.
Prove it · lab 16-btree
An index helps only as much as its rows sit together
Finding rows in an index is cheap. Fetching them from the heap is the bill, and Postgres has three ways to settle it. An Index Scan fetches each row as it walks the index, so rows come out sorted. A bitmap scan, the plan above, collects every match first, then visits each heap page once, in physical order. An index-only scan skips the heap altogether.
Here’s the uncomfortable truth: an index helps only as much as the matching rows sit together in the heap. Ask for 2% of events by a range of projects and those rows are spread over 68% of its pages. Past some point, reading the whole table in order is cheaper, and the planner switches to a sequential scan. That isn’t the planner being dumb. It’s the planner counting pages.
Deep dive: Why 2% of the rows can cost 68% of the table
About 40,000 rows from a range of projects:
The index cost 37 pages; the heap, 23,913. Recheck Cond is the heap scan re-testing each row. If the bitmap outgrows work_mem, the memory one operation may use, it goes lossy, remembering whole pages instead of rows (Heap Blocks: exact=... lossy=...), and the recheck filters every row on those pages.
Prove it · lab 16-btree
The best heap page is the one you never read
Counting project 4242’s events needs nothing the index doesn’t hold, so the heap stays closed:
The same 85 rows that cost 91 pages to fetch, counted in 5 pages.
The catch: an index entry doesn’t know whether its row is visible to you (Updates are inserts). So the scan checks the visibility map: one bit per heap page meaning “every row here is visible to everyone” (Two bits per page let scans and vacuum skip work). Where the bit is clear, it fetches the row anyway. Vacuum sets the bits.
Whenever you see Index Only Scan, check Heap Fetches. Close to the rows returned means vacuum isn’t keeping up, and the scan is quietly an ordinary one.
Multicolumn indexes: order is the design
The dashboard doesn’t ask “which rows belong to project 4242?” It asks “what are project 4242’s twenty newest events?” Index the question, not the column.
The single-column index answers the first and leaves Postgres to sort: fine for 85 events, painful for 200,000. Give the index both columns:
No sort. The project’s entries sit together in time order, so Postgres walks back from the newest and stops after 20: 3 index pages plus 20 heap pages, whether the project has 85 events or 85 million.
Cost proportional to rows returned, not rows stored. That’s what you’re designing for.
Prove it · lab 16-btree
Equality first, then range or sort
A phone book sorted by last name, then first name, finds “the Smiths, A to C” in one stretch. “Everyone named Alice” means reading the whole book.
Here is “this project, last 30 days” on (created_at, project_id), then on (project_id, created_at):
Same 11 rows, 45 times the pages. With created_at first, Postgres reads every entry from the last 30 days and checks the project on each.
Deep dive: Why the wrong order looks right in the plan
Both plans list both columns under Index Cond, which is why this mistake survives code review. What differs is which condition bounds the scan. With (created_at, project_id), only created_at >= '2026-09-06' decides where the scan starts and stops, so it reads all of the last 30 days, about 164,000 entries, testing project_id on each. The dashboard query did worse on that index: 1,399 pages, walking the whole time range backward hunting for project 4242. (The sample data ends on 2026-10-06, so the date is written out; in your app it’s now() - interval '30 days'.)
Prove it · lab 16-btree
The rule fits on a sticky note:
- Columns compared with
=(orin) go first. - Then at most one column used for a range or for
order by. - Anything after that gets checked, but doesn’t narrow what’s read.
Among equality columns, put first the one you also query alone: (a, b) serves a by itself too. PostgreSQL 18’s skip scan can rescue an index whose first column the query skips, but only when that column has few values. Don’t count on it.
Deep dive: When skip scan bails you out, and when it gives up
Skip scan works when the leading column has only a few values: the B-tree does one descent per value. An index on (kind, created_at) counted the last day’s events, a query that never mentions kind, in 44 pages instead of 35,173:
Index Searches (new in PostgreSQL 18’s EXPLAIN) counts descents from the root: 1 for a plain lookup, 6 here for five kinds: one per kind, plus one that runs off the end of the index. Nothing else in the plan says “skip”.
With thousands of leading values, the planner prices skip scan above a sequential scan. A lookup by email alone on the index behind the table’s unique (account_id, email) would need 1,000 descents, one per account, so it still read the whole table.
Prove it · lab 16-btree
Covering indexes: cover the hot query, not every query
Say the dashboard shows only created_at and kind. The 50 newest still cost 53 pages, nearly all heap pages fetched to read kind. Carry it in the index as a passenger:
Now it’s an Index Only Scan reading 6 pages. INCLUDE (PostgreSQL 11+) stores extra columns only in the leaves. The price: the index grew from 60 MB to 77 MB, and every insert writes those bytes. Cover the two or three queries that run thousands of times a second. Nothing else. Because INCLUDE columns sit outside uniqueness, it’s also the only way to add columns to a unique index without changing what “unique” means.
Deep dive: What a covering index costs when vacuum falls behind
Update the project’s 85 rows, which clears the all-visible bits on their pages, then run the 50-row query before and after vacuum events. Only these lines differ:
Same plan, 180 pages against 5. There are more heap fetches (99) than rows returned (50) because the update left a dead index entry beside each new one, and the scan has to visit the heap to learn which of the two is visible. On an append-only table autovacuum rarely visits (common before PostgreSQL 13 added insert-triggered autovacuum), Heap Fetches creeps up to the row count and the covering index quietly stops covering. InnoDB plays by different rules (Covering indexes come for free in InnoDB, almost).
Prove it · lab 16-btree
Index the rows you ask about, not the whole table
The on-call page wants a slice of the table, not all of it. It lists recent failed deploys, about 6% of events, and reads the whole table to do it. A partial index holds only the rows matching its where clause:
Now it reads 11 pages. At 2,768 KB, against 43 MB for (created_at) on every row, it stays in cache, and inserts that don’t match never touch it. Queues and soft deletes are the classic uses.
One trap: the planner must prove at plan time that your where implies the index’s. Write the predicate’s constants as literals. If your ORM makes them parameters, a generic plan can ignore the index.
Deep dive: Why a prepared statement can ignore a partial index
A generic plan, built once for any parameter value, can’t know what $1 will be (Prepared statements switch plans after five runs). With the 43 MB (created_at) index also in place:
The partial index is ignored; the plan walks the full index and filters. Use force_generic_plan in a test to see what your query’s sixth execution will do. A partial unique index is how “email is unique among non-deleted users” works (A soft delete needs a partial unique index).
Prove it · lab 16-btree
An index stores exactly what you gave it
An index holds the values you indexed, not what a function makes of them. Wrap the column in a function and the index can’t see you: where lower(email) = 'user4242@example.com' read all 1,126 pages of users. Index the expression:
Now it reads 4 pages. The query must use the same expression, written the same way; run analyze users afterward so the planner has statistics on it.
Make it unique and it enforces what a plain constraint can’t:
Now USER4242@example.com is rejected as a duplicate of user4242@example.com. A unique index already answers queries, so never add a second, non-unique one on the same columns “for performance”.
Deep dive: Why created_at::date can’t be indexed, and what to write instead
Index expressions accept only immutable functions: same input, same output, forever. Casting a timestamptz to date isn’t, because the day a moment falls on depends on the session’s time zone, and Postgres refuses with functions in index expression must be marked IMMUTABLE. Spell out the zone and it works: ((created_at at time zone 'UTC')::date). Never wrap the cast in your own function labeled immutable to get past the check: the index stores days computed in the zone of whichever session wrote each row, and a query from another zone misses rows.
For plain columns, prefer a unique table constraint to a unique index: it shows in \d, can be deferrable, and on conflict on constraint can name it. Use the index form for expressions and where clauses.
Prove it · lab 16-btree
Deduplication: repeated keys are stored once (PostgreSQL 13+)
The index on project_id is 14 MB. The primary key, also a bigint over the same rows, is 43 MB. The difference is deduplication: Postgres stores a repeated key once, followed by the list of rows that have it, which shrinks indexes on foreign keys and statuses to about a third of their size.
Smaller doesn’t mean useful. Each of the five values of kind matches at least an eighth of the table, so its rows sit on nearly every page, and an index on kind alone read more pages for kind = 'alert' than a sequential scan. A column with a handful of values belongs inside a multicolumn or partial index, never alone.
Deep dive: Inside a posting list, and which types can’t deduplicate
Rebuild the project_id index with (deduplicate_items = off) and it’s 43 MB, like the primary key. Inside the leaves:
Thirteen fat posting lists hold what took 367 plain entries. (The kind index dedupes to 13 MB, but the kind = 'alert' bitmap scan still visited 35,154 of the table’s 35,173 pages plus 213 index pages.)
Deduplication is off wherever equal values can differ in bytes: numeric, jsonb, float4 and float8, arrays and other container types, and text with a nondeterministic collation. It’s also off for any index with INCLUDE columns, part of why the covering index grew. allequalimage in bt_metap says whether an index qualifies; indexes carried over from PostgreSQL 12 or earlier by pg_upgrade need a reindex to get it.
Prove it · lab 16-btree
Every index is a tax on writes
Every index makes some read faster and every write slower, forever. We inserted the same 200,000 events three times, adding indexes each time:
| Indexes on the table | Buffer accesses | WAL written |
|---|---|---|
| none | 211,120 | 32 MB |
| primary key | 561,333 | 45 MB |
| primary key + 4 secondary | 2,363,118 | 106 MB |
Four ordinary secondary indexes made the same insert touch about four times the buffers and more than double its WAL (Write it down first).
Updates pay too: one that changes no indexed column and fits on its page is HOT and skips every index, and any other adds an entry to all of them (A cold update pays for every index).
You pay for every index on every insert, every non-HOT update, every vacuum, and in cache, WAL, replicas, and backups. Add one for a query you can name, measure before and after, and drop what nothing uses.
Deep dive: How we measured the insert tax
Each run is the same insert under explain (analyze, buffers, wal); between runs, truncate events_w and add indexes:
Each index entry is its own descent, its own dirty page, its own WAL record. WAL varies by a few MB between runs, depending on whether a checkpoint lands mid-insert and forces full-page images; the buffer counts don’t. Random keys like UUIDv4 make the tax worse, because every insert lands on a different leaf (Increasing keys append; random keys land anywhere).
Prove it · lab 16-btree
An idx_scan of zero is a suspect, not a verdict
An index takes one line to add, and nobody ever removes one. pg_stat_user_indexes counts scans of every index, so an idx_scan of zero is a suspect. Before you drop one:
- Check how long the counters have run (
stats_resetinpg_stat_database). The month-end report’s index looks unused for 29 days. On PostgreSQL 16+,last_idx_scansays when each index was last used, which is easier to reason about than a reset date. - Check every replica. Counters are per server; an index idle on the primary may carry every replica.
- Leave unique and primary key indexes alone. They enforce constraints.
- Drop with
drop index concurrently(Change the schema, keep the site), and keep thecreate indexhandy.
Duplicates need less judgment. Someone adds (project_id) for a foreign key, someone later adds (project_id, created_at) for the dashboard, and the first now only costs writes: the longer index serves every lookup the shorter one did.
Deep dive: Two queries that find indexes to drop
Unused indexes, biggest first:
The second row is a copy of the project_id index we planted. The first is the (created_at) index from the partial-index comparison: we only ever ran explain on it without analyze, which doesn’t execute the query, so it never counted as a scan.
Indexes whose key columns are a prefix of another’s:
It compares column numbers only (no operator classes, collations, or sort direction) and skips partial and expression indexes, so read each hit before acting.
Prove it · lab 16-btree
Design an index from a query, not a column
- Start from a query, not a column. Get the real SQL from
pg_stat_statements(Start with pg_stat_statements) and write down the pages its plan touches. - Put equality columns first, then one range or
order bycolumn, and let the index do the sorting. - Consider a partial index for a fixed subset, and
INCLUDEonly for a very hot query. - Look for an existing index this one makes redundant.
- Build it with
create index concurrently(Pick the index build that matches the table’s traffic) and compare pages. No drop of an order of magnitude? Find out why before you keep it.
The short version
- A lookup costs the tree’s depth (3 or 4 pages) plus one heap page per matching row.
- Index the question: equality columns first, then one range or sort column. The wrong order turned 14 pages into 643.
- Index-only scans need a current visibility map. Watch
Heap Fetches. - Partial indexes are small and cheap; write their predicate as literals.
- Never index a column with a handful of values on its own.
- Every index taxes every write: four extra made an insert touch four times the buffers.
- Audit
idx_scan = 0over a long window, on every replica.
Further reading
- Indexes, PostgreSQL documentation. The official chapter, including multicolumn, index-only, partial, and expression indexes.
- B-Tree Indexes: implementation, PostgreSQL documentation. Structure, deduplication, and bottom-up deletion.
- src/backend/access/nbtree/README, PostgreSQL source. Right-links, high keys, and page deletion from the people who wrote them.
- Efficient Locking for Concurrent Operations on B-Trees, Philip L. Lehman and S. Bing Yao, ACM TODS, 1981. The B-link tree behind Postgres’s sibling links.
- PostgreSQL 18 release notes, PostgreSQL. Skip scan and the new
Index Searchesline. - Anatomy of an SQL Index, Markus Winand. The clearest mental model of leaf chains and tree depth.