Most production queries filter on more than one column. A composite (multi-column) index can serve several of these queries at once — but only if its column order matches how the query filters. This file covers that ordering rule in depth, since getting it wrong is one of the most common indexing mistakes in production systems.
A support dashboard queries orders by customer, then by date, then by status:
SELECT * FROM orders
WHERE customer_id = 88291
AND order_date > '2026-01-01'
AND status = 'completed';
Three single-column indexes exist as an option, but MySQL can typically
use only one index per table per query block efficiently (barring index
merge, which has its own overhead). A single composite index ordered
(customer_id, order_date, status) can serve this exact filter pattern in
one seek — this is the entire motivation for composite indexes.
A composite index is sorted first by its first column, then by its second
column within each value of the first, and so on. This nested sort
order means the index can only be searched efficiently starting from its
leftmost column — you cannot binary-search a structure sorted by
(customer_id, order_date) using only order_date, for the same reason
you can’t look up a phone book by first name when it’s sorted by last
name.
(tenant_id, created_at) — nearly every query in a
multi-tenant system filters by tenant first.(customer_id, order_date, status).(user_id, event_type, timestamp).INDEX (customer_id, order_date, status)
Sorted as:
customer_id=4471, order_date=2026-01-02, status='completed'
customer_id=4471, order_date=2026-01-05, status='pending'
customer_id=4471, order_date=2026-01-09, status='completed'
customer_id=88291, order_date=2026-01-01, status='completed'
customer_id=88291, order_date=2026-01-03, status='cancelled'
customer_id=91002, order_date=2026-01-01, status='pending'
Notice: within customer_id, rows are sorted by order_date; within
each (customer_id, order_date) pair, by status. This is why the
index is only directly searchable starting from customer_id.
CREATE INDEX idx_orders_customer_date_status
ON orders (customer_id, order_date, status);
CREATE INDEX is not stylistic — it is the physical
sort order and directly determines which query shapes benefit.INDEX(customer_id, order_date, status)
✓ WHERE customer_id = X -- uses col 1
✓ WHERE customer_id = X AND order_date = Y -- uses cols 1-2
✓ WHERE customer_id = X AND order_date = Y AND status = Z -- uses all 3
✗ WHERE order_date = Y -- skips col 1
✗ WHERE status = Z -- skips cols 1-2
✗ WHERE order_date = Y AND status = Z -- skips col 1
customer_id (sorted)
/ | \
4471 88291 91002
/ \ | \
order_date order_date ...
(sorted within (sorted within
customer_id customer_id
=4471) =88291)
Each level of sort only exists within the level above it — you must enter the tree from the top (leftmost column) to benefit from any of it.
WHERE clause for a contiguous prefix of the
composite index’s column list, starting from column 1.Equality columns should generally precede range columns in a composite
index. (customer_id, status, order_date) for a query filtering
customer_id = X AND status = 'completed' AND order_date > Y lets the
index narrow by two equalities before applying the range — placing
order_date earlier would end the useful sort narrowing at the first
range predicate, since everything after a range column in the index
can’t be used for further seeking within that same lookup.
A composite index stores all indexed columns’ values at every leaf entry, not just the first — a 3-column composite index on large VARCHAR columns can be significantly larger than a single-column index on the same table.
MySQL 8.0+ can sometimes use index merge to combine two separate single-column indexes for a query, but this is generally more expensive than a single well-ordered composite index and should be treated as a fallback the optimizer reaches for, not a design strategy to rely on.
As with all index structures, composite index behavior is implementation-specific; the standard has no concept of “leftmost prefix” since it doesn’t define physical access paths at all.
btree
composite indexes.INCLUDE (File 05) — a plain
composite index follows the same leftmost rule as MySQL/PostgreSQL.ORDER BY can sometimes be satisfied by a composite index’s trailing
columns even without a filter on them, but only if the leading columns
are constrained by equality predicates first.ORDER BY or
covering (File 05).WHERE status = Z alone gets zero benefit from
INDEX(customer_id, order_date, status).INDEX(a, b, c), which of these queries can use the index, and
how much of it: WHERE a = 1, WHERE b = 2, WHERE a = 1 AND c = 3,
WHERE a = 1 AND b = 2 AND c = 3?A composite index is one physical structure, sorted by its columns in declared order, nested left to right. The leftmost prefix rule follows directly from that physical sort: the index is only searchable starting from its first column, and a well-designed composite index should mirror actual query filter patterns — equality columns first, then at most one range column, then any trailing columns needed for ordering or covering.
See 12_PRACTICE_PROBLEMS.md, Intermediate section, Problems 4–7.