Composite indexes and index design
Column order in a multicolumn index
The idea in 30 seconds
A multicolumn B-tree is sorted by its first column, then by the second within each first-column value, and so on. Put the columns you test with = first and the one you filter by range or sort on last, and the rows you want sit together in one short stretch of the index. PostgreSQL 18 can now skip through a leading column it was not given, but only cheaply when that column has few distinct values.
Helps to know first: B-tree indexes, PostgreSQL index types
On this page
A phone book sorted twice
A phone book is sorted by surname, then by first name within each surname. Finding “Rao, Asha” is quick. Finding everyone called “Asha” means reading the whole book, because the Ashas are scattered under every surname.
A multicolumn index works the same way. An index on (customer_id, ordered_at) is sorted by customer, and within each customer by date. One customer’s orders from one quarter are a single short run of entries.
Equality first, range last
PredictSame columns, other order
Two million orders from 20,000 customers. The query is “customer 4242’s orders in the first quarter of 2025”, 11 rows. With an index on (customer_id, ordered_at) it reads 14 pages. How many pages does it read with the same two columns in the other order, (ordered_at, customer_id)?
962 pages, about 70 times more. PostgreSQL 18.3, second run of each:
| Index | Pages read | Time |
|---|---|---|
(customer_id, ordered_at) | 14 | 0.01 ms |
(ordered_at, customer_id) | 962 | 2.6 ms |
With the date first, the index is sorted by date, and the quarter covers about a quarter of a million entries from every customer. PostgreSQL walks all of them, checking each one’s customer_id. With the customer first, it jumps to customer 4242, and that customer’s orders are already sorted by date.
The rule from the documentation: equality conditions on the leading columns, plus a range on the next column, decide how much of the index is read. Conditions on columns after that are still checked inside the index, but no longer narrow it. So:
- Columns compared with
=first. - Then the one column used for a range (
<,>,BETWEEN) or forORDER BY. - Anything after that only filters.
Skipping the first column
Until PostgreSQL 18, a query that did not constrain an index’s first column could only use the index by reading all of it. PostgreSQL 18 added skip scan: it can run one search per distinct value of the leading column, as if you had written status = 'paid', then status = 'shipped', and so on.
On the same two million orders, counting one day’s orders (2,670 rows) without mentioning status:
| Index | Pages | Time | How |
|---|---|---|---|
| None useful | 16,667 | 123 ms | Sequential scan |
(status, ordered_at), 4 statuses | 44 | 0.23 ms | Skip scan: 9 index searches |
(customer_id, ordered_at), 20,000 customers | 16,667 | 126 ms | Planner chose a sequential scan |
(ordered_at) | 11 | 0.17 ms | Ordinary index scan |
Skip scan made (status, ordered_at) nearly as good as a dedicated index, because it only had a handful of statuses to skip through. With 20,000 customers in front, there were too many to skip, and forcing the index made it read 7,666 pages in one long pass. Skip scan rescues an index with a low-cardinality first column; it does not make column order stop mattering.
Check yourself
Why did
(ordered_at, customer_id)read 70 times more pages for one customer’s quarter?Show answer
The index is sorted by date first, so one quarter’s entries from every customer sit together and must all be read to find one customer’s. With
customer_idfirst, that customer’s entries are together and already in date order.What order would you choose for a query
WHERE tenant_id = $1 AND status = $2 AND created_at > $3?Show answer
(tenant_id, status, created_at): both equality columns first, then the range column.When does PostgreSQL 18’s skip scan help, and when does it not?
Show answer
When the query skips an index’s leading column and that column has few distinct values, so one search per value is cheap. With many distinct values, as with 20,000 customers, it is not worth it.
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
Equality constraints on leading columns plus an inequality on the first column without equality limit the portion of the index scanned; skip scan generates internal equality constraints for a leading column without one; multicolumn indexes should be used sparingly.
- 2.PostgreSQL documentation: E.4 Release 18Primary· E.4.1 Overview
PostgreSQL 18 adds skip scan lookups for multicolumn B-tree indexes.
- 3.PostgreSQL documentation: 11.4 Indexes and ORDER BYPrimary· §11.4 Indexes and ORDER BY
A B-tree can deliver rows in the order of its columns, avoiding a sort.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 11.3 Multicolumn IndexesPrimary· §11.3
The exact rule for which conditions narrow a multicolumn index scan, with the skip scan explanation added in version 18.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.