Skip to content
EL
Contents

Composite indexes and index design

Column order in a multicolumn index

ConceptAdvanced3 min read15 min hands-on#indexing#query-planning

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)?

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:

  1. Columns compared with = first.
  2. Then the one column used for a range (<, >, BETWEEN) or for ORDER BY.
  3. 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:

IndexPagesTimeHow
None useful16,667123 msSequential scan
(status, ordered_at), 4 statuses440.23 msSkip scan: 9 index searches
(customer_id, ordered_at), 20,000 customers16,667126 msPlanner chose a sequential scan
(ordered_at)110.17 msOrdinary 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

  1. 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_id first, that customer’s entries are together and already in date order.

  2. 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.

  3. 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. 1.
    PostgreSQL documentation: 11.3 Multicolumn Indexes
    Primary· §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. 2.
    PostgreSQL documentation: E.4 Release 18
    Primary· E.4.1 Overview

    PostgreSQL 18 adds skip scan lookups for multicolumn B-tree indexes.

  3. 3.
    PostgreSQL documentation: 11.4 Indexes and ORDER BY
    Primary· §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. 1.
    PostgreSQL documentation: 11.3 Multicolumn Indexes
    Primary· §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.