B-tree indexes
Should this index ship?
The idea in 30 seconds
An index is a decision with a cost on every write, so review it like code. Start from the query, not the columns: one index shaped like the query usually beats one index per column. Build it without blocking writes, and check afterwards that something actually uses it.
Helps to know first: How a table is stored on disk (coming soon)
On this page
The situation
Your product has an assets page. For each customer organization it lists the 50 newest active assets:
SELECT id, name, created_at
FROM assets
WHERE org_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 50;
The assets table has 2 million rows from 1,000 organizations and is written to all day. The largest customer owns 10% of the rows; most own a fraction of a percent. About 95% of assets are active. Right now the table has no indexes besides the ones this change adds, and the page is slow.
An AI assistant opened a pull request to fix it.
Review taskAdd indexes for the assets page
Pull request: “Speed up the assets list. Adds an index for every column the query filters or sorts on.”
-- migrations/20261018_add_asset_indexes.sql
CREATE INDEX idx_assets_org_id ON assets (org_id);
CREATE INDEX idx_assets_created_at ON assets (created_at);
CREATE INDEX idx_assets_status ON assets (status);Would you approve it? Write the review you would leave.
Your call
Model review
Request changes. The intent is right and the page will get faster for the biggest customer, but this migration ships three problems.
1. Three indexes where the query needs one. For this query PostgreSQL used just one of them: it walked created_at backwards and threw away rows from other organizations until it had 50 matches. That is fine for the largest customer, who owns 10% of the rows, and terrible for a small one:
| Organization | Indexes in this PR | One index on (org_id, created_at) |
|---|---|---|
| Largest (200,000 rows) | 458 pages · 0.5 ms | 57 pages · 0.1 ms |
| Small (1,802 rows) | 66,767 pages · 410 ms | 56 pages · 0.1 ms |
A single index on (org_id, created_at) matches the query’s shape: it jumps straight to one organization, and inside it the entries are already in created_at order, so PostgreSQL reads the newest 50 from the end and stops.
2. Two of the indexes are never used. After running the query for both organizations, pg_stat_user_indexes showed idx_scan = 0 for idx_assets_org_id (14 MB) and idx_assets_status (13 MB). They would slow down every write to a busy table and help nothing. status is also a poor index on its own: when 95% of rows match, reading the index first saves almost nothing.
3. It blocks writes while it runs. A plain CREATE INDEX takes a SHARE lock on the table, so every INSERT, UPDATE and DELETE waits until the build finishes. On a large, busy table that is an outage. Use CREATE INDEX CONCURRENTLY, which keeps writes flowing but takes longer, cannot run inside a transaction block, and can leave an INVALID index behind if it fails. If your migration tool runs each migration inside a transaction, the concurrent build will fail; check how yours runs them.
What I would ask for instead:
CREATE INDEX CONCURRENTLY idx_assets_org_created ON assets (org_id, created_at);…together with EXPLAIN (ANALYZE, BUFFERS) output before and after, for a large and a small organization, in the pull request description.
Compare it with yours. Did your review catch the main problems?
How to decide on an index
Start from the query, not the columns. Write down the queries the index is for. “Index every column in the WHERE clause” is how you end up with this pull request.
Prefer one index shaped like the query, unless different queries need different leading columns. Put the columns compared with = first, then the column used for a range or for ORDER BY. Equality on the leading columns plus a range or order on the next column lets PostgreSQL read one contiguous slice of the index.
Prefer no index on a column most rows share, unless a query needs the rare values. An index on status where 95% of rows are active costs a write on every insert and almost never wins a plan. Indexes that cover only the rare rows come later, in PostgreSQL index types.
Build with CONCURRENTLY on any table that takes writes, unless the table is new or empty. Then check the result: if the build failed, drop the INVALID index and run it again.
Test with your skew. The pull request’s indexes were fine for the biggest customer, but for a small one they read about 1,200 times more pages than the composite index. Always try the extremes.
Check afterwards that something uses it. Look at idx_scan in pg_stat_user_indexes after the index has seen real traffic, and drop indexes that stay at zero.
Reproduce the evidence
The numbers in the review come from this setup on PostgreSQL 18.3. Run it on a throwaway database to see the plans yourself.
CREATE TABLE assets (id bigint, org_id int, created_at timestamptz, status text, name text);
INSERT INTO assets
SELECT g,
CASE WHEN g % 10 = 0 THEN 1 ELSE 2 + (g % 999) END, -- org 1 owns 10% of rows
timestamptz '2026-01-01' + random() * interval '270 days',
CASE WHEN random() < 0.95 THEN 'active' ELSE 'archived' END,
'asset-' || g
FROM generate_series(1, 2000000) g;
VACUUM ANALYZE assets;
-- The pull request's indexes:
CREATE INDEX idx_assets_org_id ON assets (org_id);
CREATE INDEX idx_assets_created_at ON assets (created_at);
CREATE INDEX idx_assets_status ON assets (status);
ANALYZE assets;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, created_at FROM assets
WHERE org_id = 500 AND status = 'active' ORDER BY created_at DESC LIMIT 50;
SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE relname = 'assets';
-- The alternative:
DROP INDEX idx_assets_org_id, idx_assets_created_at, idx_assets_status;
CREATE INDEX idx_assets_org_created ON assets (org_id, created_at);
ANALYZE assets;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, created_at FROM assets
WHERE org_id = 500 AND status = 'active' ORDER BY created_at DESC LIMIT 50;
The statistics view updates shortly after each query, so run the pg_stat_user_indexes query a second or two after the EXPLAIN. Timestamps and statuses are random, so your page counts will differ a little; the gap between the two plans will be the same order of magnitude.
Check yourself
A query filters on
org_id = $1and sorts bycreated_at DESC LIMIT 50. Why does one index on(org_id, created_at)beat separate indexes on each column?Show answer
Inside one
org_id, the composite index’s entries are already sorted bycreated_at, so PostgreSQL jumps to that organization and reads the newest 50 entries. With separate indexes it can follow only one: walkingcreated_atmeans discarding every other organization’s rows until 50 match.Why did the pull request’s indexes look fine for the largest customer and terrible for a small one?
Show answer
Walking
created_atfrom the newest finds 50 rows quickly when the organization owns 10% of the table, but has to pass tens of thousands of other organizations’ rows when it owns 0.1%.What happens to writes during a plain
CREATE INDEX, and what do you give up withCONCURRENTLY?Show answer
A plain build takes a
SHARElock, so inserts, updates and deletes wait until it finishes.CONCURRENTLYkeeps writes flowing but does more total work, takes longer, cannot run in a transaction block, and can leave anINVALIDindex behind that you must drop and rebuild.How do you find out whether an index is earning its keep?
Show answer
Check
idx_scaninpg_stat_user_indexesafter a period of real traffic. An index that stays at zero is pure write and storage cost.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 11.3 Multicolumn IndexesPrimary· §11.3 Multicolumn Indexes
A multicolumn B-tree is most efficient with constraints on its leading columns; equality on leading columns plus a range on the next one limits the part of the index scanned.
- 2.PostgreSQL documentation: 11.4 Indexes and ORDER BYPrimary· §11.4 Indexes and ORDER BY
With ORDER BY … LIMIT n, an index matching the ORDER BY returns the first n rows without scanning the rest.
- 3.PostgreSQL documentation: CREATE INDEXPrimary· Building Indexes Concurrently
A plain CREATE INDEX locks the table against inserts, updates and deletes for the whole build; CONCURRENTLY avoids that, takes longer, cannot run inside a transaction block, and can leave an INVALID index behind on failure.
- 4.PostgreSQL documentation: 13.3 Explicit LockingPrimary· §13.3.1 Table-Level Locks: SHARE
CREATE INDEX without CONCURRENTLY takes a SHARE lock, which conflicts with the ROW EXCLUSIVE lock that writes need.
- 5.PostgreSQL documentation: 27.2 The Cumulative Statistics SystemPrimary· §27.2 pg_stat_all_indexes: idx_scan
idx_scan counts the index scans started on each index.
- 6.PostgreSQL documentation: 11.1 Indexes, IntroductionPrimary· §11.1 Introduction
Indexes that are seldom or never used in queries should be removed.
Go further
What to read next, and why it is worth the time.
- 1.Use The Index, Luke!: Anatomy of an SQL IndexReferenceMarkus Winand· Chapter 2: The Where Clause, Concatenated Indexes· location not yet verified
The clearest explanation of why column order in a multicolumn index matters, with diagrams.
- 2.PostgreSQL documentation: CREATE INDEXPrimary· Building Indexes Concurrently
Read the whole section once before you add an index to a busy production table.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.