Leftmost Prefix Rule
On the last page, we built one composite index for "My Orders": (user_id, status, created_at). It made that query read 20 rows instead of 500. We also saw a catch: it only helps queries that match its column order. This page explains exactly which queries it can and can't help.
The Problem: A New Query Ignores Our Index
Our food delivery app adds an admin dashboard. Support staff need every order that is still pending:
SELECT * FROM orders WHERE status = 'pending';
We already have an index that contains status, so this should be fast. But EXPLAIN ANALYZE shows a Seq Scan: the database reads all 10 million rows.
1Query: status = 'pending'2Index: (user_id, status, created_at)3Plan: Seq Scan on orders → full scan, index ignoredThe index has the column, but the database can't use it. Here's why.
Why: How the Index Is Sorted
Remember the phone book: sorted by last name, then first name.
- Find "Sharma" → easy, they're all together.
- Find "Sharma, Rahul" → easy, inside the Sharma group.
- Find everyone named "Rahul" → useless. Rahuls are spread across every last name.
Our index works the same way. Look where the pending orders sit:
1(user_id, status, created_at)2(41, delivered, 2026-10-25)3(41, pending, 2026-10-27) ← pending4(42, delivered, 2026-10-20)5(42, pending, 2026-10-27) ← pending6(43, cancelled, 2026-10-03)7(43, pending, 2026-10-26) ← pending8... scattered across every userstatus is only sorted inside each user_id. Across the whole index, pending orders are scattered everywhere, so there is no single spot to jump to.
That gives us the rule.
The Rule
An index on (A, B, C) can be used when the query filters on a prefix, meaning columns starting from the left with no gaps:
1(A) ✓2(A, B) ✓3(A, B, C) ✓4(B) ✗ skips A5(C) ✗ skips A and B6(B, C) ✗ skips A7(A, C) ~ partly: uses A, then checks C on every entry for that AFor our index (user_id, status, created_at):
| Query filters on | Uses the index? | Why |
|---|---|---|
user_id | Yes | Leftmost column |
user_id + status | Yes | Prefix, no gap |
user_id + status + created_at | Yes | Full prefix |
status | No | Skips user_id |
created_at | No | Skips two columns |
status + created_at | No | Skips user_id |
user_id + created_at | Partly | Jumps to user 42, but must check every one of their entries for the date, because status sits in between |
So how do we fix the admin dashboard?
Fixing Queries That Don't Match
Option 1: Add an index that starts with the missing column.
CREATE INDEX idx_orders_status_dateON orders (status, created_at);
This serves status alone and status + created_at, such as "pending orders, oldest first".
Option 2: Reorder an existing index, if no other query needs the current order. Here, "My Orders" needs user_id first, so we can't.
| Add a second index | Reorder the existing index | |
|---|---|---|
| Fixes new query | Yes | Yes |
| Breaks old queries | No | Maybe |
| Write cost | +1 write per order | Same as now |
| Choose when | Both query shapes are important | Old order isn't needed anymore |
Before adding an index, check how often the query runs. A rare admin report on a small table may be fine with a full scan. A query that runs thousands of times a second is not.
Two Things People Get Wrong
1. The order in the WHERE clause doesn't matter. The rule is about which columns the query uses, not how you write them. These two queries both use the index:
WHERE user_id = 42 AND status = 'delivered'WHERE status = 'delivered' AND user_id = 42
2. The rule applies to ORDER BY too.
Data is sorted by created_at only inside each (user_id, status) group. So WHERE user_id = 42 ORDER BY created_at still needs an extra sort, because status sits in between. WHERE user_id = 42 AND status = 'delivered' ORDER BY created_at does not.
A note on newer versions: PostgreSQL 18 added skip scan, which can sometimes use an index without its first column when that column has only a few distinct values. It helps in limited cases. Don't design around it; design indexes that match your queries.
Interview Angle
How to Explain it in an Interview
Common mistakes:
- Thinking "the column is in an index, so the query is indexed".
- Thinking the order of conditions in
WHEREmust match the index.
Summary
1Index (A, B, C) → sorted by A, then B inside A, then C inside B2Works for → A | A+B | A+B+C3Fails for → B | C | B+C (skips the leftmost column)4Partial → A+C (gap at B)5WHERE order → doesn't matter, columns do6ORDER BY → also needs the columns before it fixed7Fix → add an index that starts with the needed columnWe now know which queries a composite index serves. Next, the ESR rule shows how to pick the best column order when a query mixes equality, sorting, and ranges.