Skip to content

Composite Indexes: One Ordering, Not Three

Column order decides which queries a composite index can serve. The rule follows from the structure, and so do the cases where it does not hold.

Elias Rowe

3 min readPostgreSQL 18

Composite Indexes: One Ordering, Not Three — SQL Fundamentals article cover

An index on (customer_id, created_at, status) serves some queries and not others, and which is which is not arbitrary. It follows entirely from how the entries are ordered on disk.

How is a composite index sorted?

A composite B-tree index is sorted by the first column. Within each value of the first column, entries are sorted by the second. Within each pair, by the third. There is one ordering, not three.

That is the whole model. A phone book sorted by last name, then first name, is the same structure: you can find everyone named Rowe, and among them everyone named Elias, but you cannot use it to find everyone named Elias regardless of surname. The Eliases are scattered through the book in as many places as there are surnames.

What follows

You can seek on a leading prefix. (a), (a, b), and (a, b, c) are all usable. Each narrows the range further within the previous one.

You cannot seek on a non-leading column alone. A predicate on b with nothing on a has no contiguous range to descend into. The engine may still read the whole index and filter — cheaper than the table if the index is much narrower, and a choice the planner makes from its row estimate — but that is a scan, not a seek, and it does not scale the way an index is supposed to.

Equality before range. This is the rule people get wrong most often. Once a predicate opens a range, everything after it is spread across that range:

order-matters.sql
-- Index on (created_at, status): the range opens first.
WHERE created_at >= '2026-01-01' AND status = 'pending'
-- Only created_at narrows the seek; status is a filter over everything after it.
-- Index on (status, created_at): equality first.
WHERE status = 'pending' AND created_at >= '2026-01-01'
-- Both narrow the seek: one status value, then a contiguous date range inside it.

Same predicates, same two columns, different amount of work.

Ordering comes free in one direction. An index on (a, b) can satisfy ORDER BY a, b without a sort. It cannot satisfy ORDER BY b, a. Mixed directions depend on the engine supporting descending index columns.

The exceptions

Skip scan. When the leading column has very few distinct values, an engine can loop over each of them and perform a seek on the rest. MySQL 8.0.13 added this as the Skip Scan Range Access Method, and Oracle Database has had index skip scan for much longer. It makes the leftmost-prefix rule a strong default rather than a law — but it depends on low cardinality in the leading column, so it does not rescue a badly ordered index in general. PostgreSQL 18 added B-tree skip scan; SQL Server’s Showplan operator reference lists no equivalent access method.

Full index scan as a narrow table. If the index is far smaller than the table and the query touches only indexed columns, reading the entire index and filtering can beat reading the table, even with no usable prefix. This is a real optimization and a bad thing to design for.

What order should composite index columns go in?

Take the queries the index is meant to serve. List the columns used with equality first, ordered by how many queries share them. Add the range column next. Add columns needed only for output last, and consider whether they belong as key columns at all — which is the subject of the next part.

One index in the right order usually replaces two in the wrong order, and each index you avoid creating is write throughput you keep. MySQL can make the redundant one invisible to the optimizer before it is dropped, which makes the removal a reversible test rather than a destructive change.

Limits

This describes B-tree indexes. Hash, GIN, and BRIN indexes have different structures and different rules; a GIN index over a composite type does not behave like a leftmost prefix at all. The reasoning here transfers only as far as the sorted-tuple structure does.

Frequently asked questions

Why can't my query use the second column of a composite index?
A composite B-tree index is sorted by its first column, then by the second within each value of the first. A predicate on the second column alone has no contiguous range to descend into, because those values are scattered across every value of the leading column. The engine can still read the whole index and filter, but that is a scan rather than a seek, and it does not scale the way an index is supposed to.
Should equality or range columns come first in a composite index?
Equality first. Once a predicate opens a range, every column after it is spread across that range and can only be used as a filter, not to narrow the seek. An index on status then created_at serves a query for one status inside a date window by seeking twice: one status value, then a contiguous date range within it. The same index with the columns reversed narrows on the date alone.
When does the leftmost-prefix rule not apply?
Skip scan is the main exception. When the leading column holds very few distinct values, an engine can loop over each of them and seek on the remaining columns. MySQL added this as the Skip Scan Range Access Method in 8.0.13, Oracle Database has had index skip scan far longer, and PostgreSQL 18 added it for B-tree indexes. It depends on low cardinality in the leading column, so it does not rescue a badly ordered index in general.
Can a composite index satisfy ORDER BY without a sort?
An index on (a, b) can satisfy ORDER BY a, b without a sort, because that is already the order its entries are stored in. It cannot satisfy ORDER BY b, a, since b is only ordered within a given a. Mixed ascending and descending directions depend on the engine supporting descending index columns.

References

  1. docsMySQL 8.4 Reference Manual — Skip Scan Range Access Method (opens in a new tab)

    The mechanism and the conditions under which the optimizer applies it.

  2. release notesMySQL 8.0.13 release notes (opens in a new tab)

    The Skip Scan access method was added in this release.

  3. release notesPostgreSQL 18 release notes (opens in a new tab)

    Skip scans of B-tree indexes were added in this release.

  4. docsOracle Database 23ai SQL Tuning Guide — Optimizer Access Paths (opens in a new tab)

    Index skip scan, and why it depends on few distinct values in the leading key.

  5. docsMicrosoft Learn — Showplan Logical and Physical Operators Reference (opens in a new tab)

    SQL Server's index access operators; no skip-scan equivalent appears in the list.

  6. docsMySQL 8.4 Reference Manual — Invisible Indexes (opens in a new tab)

    Maintained but hidden from the optimizer, so removal can be tested without dropping.

share

-- written by

Elias RoweDatabase engineer

Elias Rowe writes about database engineering, SQL performance, and production systems. He focuses on measurable behavior, practical trade-offs, and conclusions that can be reproduced rather than assumed.

Start typing to search the archive.