How do I choose the right composite index column order in PostgreSQL?
Question
I have an `orders` table with millions of rows, and my most frequent query is `WHERE user_id = X AND status = 'completed' ORDER BY created_at DESC`. It's painfully slow right now; the table has separate single-column indexes on `user_id`, `status`, and `created_at`, and when the DB tries to combine them with a Bitmap Index Scan the cost blows up. What's the most efficient composite index column order? And how does each column's selectivity (cardinality) affect that order?
Answer
Short answer: build one composite index for this query — (user_id, status, created_at DESC). Equality columns first, the ORDER BY column last; everything else falls out of that.
Short answer
The slowness isn’t a missing index, it’s the wrong index shape: with three separate single-column indexes the DB has to merge them via a Bitmap Index Scan and then run a separate Sort step — both expensive. I covered the tenant-scoped version of the same “which column should the hot table be organised by” question in the multi-tenant isolation answer.
Why
-
Equality columns belong in front, the sort column last. After applying the equality filter the B-tree already hands rows back in order, so no separate Sort step is needed. Break that order and the planner has to sort by itself.
-
Cardinality is secondary here. Both columns are equality predicates, so the real win is moving the sort into the index; still, putting the higher-selectivity column (usually
user_id) first narrows the initial scan. -
Redundant indexes aren’t free. The single-column indexes this composite covers no longer help reads but are still updated on every write.
What to do
-
Build the
(user_id, status, created_at DESC)composite. Equality columns first, the ORDER BY column last. -
Put
DESCinto the index definition. On a single column PostgreSQL can scan backwards, but pinning the direction in a composite guarantees the planner’s job and makes the sort free. -
Confirm with
EXPLAIN (ANALYZE, BUFFERS). The plan you want is a single Index Scan — notBitmap Index Scan+Sort. If you still see a Sort, your column order orDESCdirection is wrong. -
Drop the covered single-column indexes. The composite already satisfies the
user_idand(user_id, status)prefixes; the separate ones only slow writes down. -
Consider a partial index if
completeddominates. A partial index withWHERE status = 'completed'shrinks the index, may keep more of it in RAM, and lowers write cost.
Bottom line: I’d build the (user_id, status, created_at DESC) composite index, confirm with EXPLAIN (ANALYZE, BUFFERS) that it drops to an Index Scan, and then drop the single-column indexes it already covers. If your traffic piles onto one status, tighten it further with a partial index. For a deeper take on where indexing meets native SQL, see the sade.dev piece.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.