MySQL Composite Index and the Leftmost Prefix Rule
A composite index sorts by its first column, then the next. Which queries the leftmost prefix rule serves, why column order matters, and where a range stops.
- Course: SQL study plan
- Module: Indexes and performance
- Kind: Lesson
- Reading time: 15 min
- Runtime: MySQL 8 syntax on SQLite 3.49
What is the leftmost prefix rule for a composite index?
The leftmost prefix rule says a composite (multi-column) index can be searched only through a prefix of its columns, starting from the first. An index on (a, b, c) serves filters on a, on a and b, or on a, b and c, but not a filter on b or c alone, because the entries are sorted by a first and b only within each a.
Lesson
Real queries filter on more than one column: a customer's orders in a date range, a city's users by signup date, a product's reviews by rating. One index per column is not the answer — MySQL generally searches one index per table, and intersecting two (index_merge) is a fallback, not a plan you design for. The tool is a composite index, one B+tree over several columns. This lesson explains how its entries are ordered, the leftmost prefix rule that follows from it, how a range condition changes things, and how to choose the column order.
How the entries are sorted
A composite index sorts by its first column; among equal first values, by the second; among equal pairs, by the third — exactly like a phone book sorted by surname, then first name.
CREATE INDEX idx_orders_cust_date ON orders (customer_id, placed_on);
INDEX (customer_id, placed_on) → id- The entries are sorted by customer_id, and among equal customer_ids by placed_on — a phone book by surname, then first name. Each customer's dates are in order; across customers they are not.
- WHERE customer_id = 3: customer 3's entries sit together, so the search descends once to the first and reads 3 in a row. The leftmost column can always seek.
- Add placed_on >= '2024-03-01': within customer 3 the dates are sorted, so the run just starts later — 2 entries. Equality on the first column, then a range on the second, both narrow the seek.
- WHERE placed_on = '2024-03-02' alone: the two matches are rows 2 and 6, under different customers, with others between them. No single run holds them, so the index cannot seek on its second column.
Everything else follows from this picture. Customer 3's orders sit together, and within them the dates are in order. But the dates across customers are not in order — 2024-03-02 appears twice, far apart.
The leftmost prefix rule
An index on (a, b, c) can be searched through any leftmost prefix of its columns:
| Filter | Uses the index to seek? |
|---|---|
a = 1 | yes — prefix (a) |
a = 1 AND b = 2 | yes — prefix (a, b) |
a = 1 AND b = 2 AND c = 3 | yes — all three |
b = 2 | no — the b values are scattered |
b = 2 AND c = 3 | no |
a = 1 AND c = 3 | partly — seeks on a, then filters c |
The last row is the subtle one: with b missing, the server seeks to a = 1 and then has to examine every entry for that a, testing c. MySQL does that test inside the index rather than after fetching each row — index condition pushdown, shown as Using index condition in a plan — but it is still reading all of a = 1's entries.
The order of conditions in the WHERE clause is irrelevant: WHERE b = 2 AND a = 1 matches (a, b) just as well. Only the order inside the index definition matters.
Two corollaries interviewers like:
- an index on
(a)is redundant next to one on(a, b)— drop it; - an index on
(b)is not redundant next to(a, b)— it serves queries the composite cannot.
A range stops the seek
Once a column is compared with a range (<, >, BETWEEN, LIKE 'abc%'), the columns after it can no longer narrow the seek, because within a range of a values the b values are not sorted.
-- index (customer_id, placed_on, status)
WHERE customer_id = 3 AND placed_on >= '2024-03-01' AND status = 'paid'
-- seek: customer_id = 3, placed_on from 2024-03-01 onwards (two columns)
-- status: tested on each entry in that range, not used to seek
The rule of thumb that falls out: equality columns first, then the one range column. If the query above is common, (customer_id, status, placed_on) serves it fully — two equalities, then the range — and reads only the matching entries.
Sorting comes for free — when the prefix fits
Because the entries are already in order, a composite index can also satisfy ORDER BY without a sort step:
SELECT id, placed_on, amount
FROM orders
WHERE customer_id = 3
ORDER BY placed_on; -- index (customer_id, placed_on): no filesort
The equality on customer_id fixes the first column, and the remaining entries come out ordered by placed_on. ORDER BY placed_on DESC works too: InnoDB reads the leaves backwards (Backward index scan in the plan). MySQL 8 also supports descending index columns, (customer_id, placed_on DESC), which matter when one query sorts the columns in mixed directions, such as ORDER BY a ASC, b DESC.
Remove the equality and the order breaks: ORDER BY placed_on alone across all customers cannot read this index in order, so the server sorts (Using filesort).
Choosing the column order
There is no single rule, but these questions settle almost every case:
- Which columns are compared with equality? They go first.
- Which column is the range or the sort? It goes last among the useful columns.
- Which prefixes do other queries need? Order the equality columns so that the index's prefixes serve as many queries as possible — one well-ordered index often replaces three.
- Only then, selectivity. Putting the most selective column first is common advice, but prefix reuse matters more: an index on
(country, city)serves "all of India" and "Pune, India";(city, country)serves only the second.
-- queries: WHERE tenant_id = ? AND created_at >= ?
-- WHERE tenant_id = ? AND user_id = ?
-- WHERE tenant_id = ?
-- one index for the first and third, one for the second:
CREATE INDEX idx_events_tenant_time ON events (tenant_id, created_at);
CREATE INDEX idx_events_tenant_user ON events (tenant_id, user_id);
Skip scan: the exception, not the plan
MySQL 8.0.13 added the index skip scan: with an index on (gender, birth_date) and a filter on birth_date alone, the optimizer may seek once for each distinct gender value and range-scan inside each. It only pays off when the skipped leading column has very few distinct values, and it shows as Using index for skip scan. Design for the leftmost prefix; treat a skip scan as a happy accident. PostgreSQL 18 added a similar skip scan for B-tree indexes; older PostgreSQL versions could scan a multicolumn index for a non-leading column, but much less efficiently.
Limits worth knowing
- An InnoDB index can have at most 16 columns, and its key size is capped (3,072 bytes with the default row format) — long
VARCHARs can need a prefix index,INDEX (title(40)), which cannot cover a query or serveORDER BY title. - Every secondary entry also carries the primary key, so
(a, b)on a table keyed byidis effectively(a, b, id)— which is whyWHERE a = 1 ORDER BY idcan avoid a sort. - More columns mean a bigger index and more write work. Add a column because a query needs it, not in case one might.
Practise
The first exercise creates a composite index and runs the customer-and-date-range query it is built for. The second works the leftmost prefix rule out in SQL: given each index's columns in order and each query's equality filters, compute how many leading columns each index can seek on.
Common questions
How should I order the columns of a composite index?
Put columns compared with equality first and the column used for a range or ORDER BY after them, because a range stops the seek from using later columns. Among equality columns, choose the order that lets the index serve the most queries' leftmost prefixes; selectivity matters less than prefix reuse.
Does an index on (a, b) make an index on (a) redundant?
Yes, for B-tree lookups. Any query that can use an index on a alone can use the leading column of (a, b), so the single-column index only costs writes and memory. The reverse is not true: an index on (b) is not covered by (a, b).
Can MySQL use a composite index when the first column is not in the WHERE clause?
Usually not for a seek. Since MySQL 8.0.13 the optimizer can sometimes use an index skip scan, which seeks once per distinct value of the missing leading column, but only when that column has few distinct values. It may also scan the whole index if the index covers the query, which is cheaper than the table but still a full scan.
Does the order of conditions in the WHERE clause matter for a composite index?
No. The optimizer matches conditions to index columns by column, not by their order in the text, so WHERE b = 2 AND a = 1 uses an index on (a, b) exactly as WHERE a = 1 AND b = 2 does. Only the order of columns inside the index definition matters.
Exercises
A customer's month, from one index
The account page shows one customer's orders for one month. Create an index named idx_orders_cust_date on orders (customer_id, placed_on) — equality column first, range column second — then return customer 3's orders placed in March 2024. Write the month as a range on the bare placed_on column.
orders:
| id | customer_id | placed_on | amount |
|---|---|---|---|
| 1 | 3 | 2024-02-14 | 250 |
| 2 | 3 | 2024-03-02 | 180 |
| 3 | 5 | 2024-03-11 | 900 |
| 4 | 3 | 2024-03-19 | 420 |
| 5 | 7 | 2024-03-02 | 60 |
| 6 | 3 | 2024-04-01 | 310 |
| 7 | 3 | 2024-03-02 | 95 |
Output columns: id, placed_on, amount, ordered by placed_on, then id.
id | placed_on | amount
2 | 2024-03-02 | 180
7 | 2024-03-02 | 95
4 | 2024-03-19 | 420Work out the leftmost prefix
A catalog table lists each index's columns in order (seq starts at 1), and another lists the columns each query compares with equality. For every pair of a query and an index, compute how many leading columns of the index the query can seek on: the count of consecutive index columns, from seq 1, that all appear among the query's filter columns (0 when the first column is missing).
index_columns:
| index_name | seq | column_name |
|---|---|---|
| idx_cust_date | 1 | customer_id |
| idx_cust_date | 2 | placed_on |
| idx_status_cust_date | 1 | status |
| idx_status_cust_date | 2 | customer_id |
| idx_status_cust_date | 3 | placed_on |
| idx_date | 1 | placed_on |
query_filters:
| query_id | column_name |
|---|---|
| 1 | customer_id |
| 2 | customer_id |
| 2 | placed_on |
| 3 | status |
| 3 | placed_on |
| 4 | placed_on |
Output columns: query_id, index_name, usable_columns — one row for every query in query_filters and every index in index_columns, ordered by query_id, then index_name.
query_id | index_name | usable_columns
1 | idx_cust_date | 1
1 | idx_date | 0
1 | idx_status_cust_date | 0
2 | idx_cust_date | 2
2 | idx_date | 1
2 | idx_status_cust_date | 0
3 | idx_cust_date | 0
3 | idx_date | 1
3 | idx_status_cust_date | 1
4 | idx_cust_date | 0
4 | idx_date | 1
4 | idx_status_cust_date | 0In this module: Indexes and performance
← How indexes work — B+trees, clustered and secondary · Reading a query plan with EXPLAIN →