Worked examples — Composite indexes, covering indexes
Throughout, imagine one table:
orders(customer_id, status, created_at, amount, order_id)and one index we keep coming back to:
Read that as: entries are sorted first by customer_id, then by status within equal customer_id, then by created_at within equal (customer_id, status). That nested "within" is the whole story — the figure below draws it.

The scenario matrix
Every query this topic can throw at you falls into one of these cells. The examples afterwards are labelled with the cell they cover.
| # | Cell (case class) | What's special about it | Example |
|---|---|---|---|
| C1 | Full prefix, all equalities | Ideal case: tight seek to one block | Ex 1 |
| C2 | Partial prefix (gap in middle) | Column skipped → seek stops early, rest is filter | Ex 2 |
| C3 | Equality then range (right order) | Range on the LAST used column — still one block | Ex 3 |
| C4 | Range then equality (wrong order) | Range too early → later column can't seek | Ex 4 |
| C5 | Wrong leftmost column | Query filters only a non-leading column | Ex 5 |
| C6 | Covering vs non-covering | Same filter, extra table fetch removed | Ex 6 |
| C7 | Sort/ORDER BY + LIMIT + ties | Index supplies pre-sorted rows; tie-break by later key | Ex 7 |
| C8 | Degenerate / empty inputs | Zero rows, NULL, = a value that doesn't exist |
Ex 8 |
| C9 | Real-world word problem | Design the index from a feature request | Ex 9 |
| C10 | Exam twist | OR, function on column, wrong-direction sort |
Ex 10 |
Ex 1 — Full prefix, all equalities (C1)
Steps
- Check the leftmost column:
customer_idis constrained (= 42). ✅ Why this step? The index is sorted bycustomer_idfirst; without it we'd have no anchor. - Next column
statusis an equality (= 'open'), and it's the next in the index. ✅ Why? Withincustomer_id = 42, entries are sorted bystatus, so we can narrow again. - Next column
created_atis an equality too. ✅ Why? Within(42, 'open'), entries are sorted bycreated_at; one more narrowing lands us on a single contiguous block.
Answer: seeks on all 3 columns — a full-prefix equality match, the tightest possible.
Verify: and , so → full-prefix. ✔
Ex 2 — Partial prefix with a gap (C2)
Steps
customer_id = 42→ seek works. so far . ✅- The next index column is
status, but the query says nothing aboutstatus. ❌ Why does this stop us? Withincustomer_id=42, entries are ordered bystatusfirst. Since we don't fixstatus, ourcreated_atvalues are scattered across every status — not one contiguous block. created_attherefore can't be used to seek; it can only filter the rows already found bycustomer_id. Why? Seeking requires an unbroken sorted prefix; the gap atstatusbreaks it.
Answer: . Index seeks on customer_id, then filters created_at while scanning that customer's entries.

Verify: (only the leading contiguous constrained column). ✔
Ex 3 — Equality then range, right order (C3)
Steps
customer_id = 42equality → seek. . ✅status = 'open'equality, next column → seek. . ✅created_at > '2024-01-01'is a range, and it's on the last used column. ✅ Why is a range OK here? Within(42, 'open'), entries are sorted bycreated_at. A range like> Xmaps to one contiguous tail of that block — seek toX, then read forward. That's still a single block.
Answer: ; all three participate — two equalities plus a range that reads a contiguous slice.

Verify: equalities , one range on the next column counted in , so . ✔
Ex 4 — Range then equality, wrong order (C4)
Steps
customer_id = 42equality → seek. . ✅status > 'a'is a range onstatus(the 2nd column). It seeks a contiguous slice of statuses, and being the one allowed range it counts in . . ✅created_at = '2024-05-01'— but we just did a range onstatus. ❌ Why blocked? Oncestatusspans a range of values, thecreated_atordering resets for each differentstatus. Socreated_atvalues are scattered again — no single block to seek.created_atbecomes a filter only.
Answer: . The range on status "uses up" our one range and stops created_at from seeking.

Verify: equalities before the range , plus the one counted range , so (not 3). ✔
Ex 5 — Wrong leftmost column (C5)
Steps
- Leftmost index column is
customer_id. The query does not constrain it. ❌ Why fatal? The entire index is sorted bycustomer_idfirst. Every customer has its ownstatus='open'entries, scattered from the top of the index to the bottom. There is no contiguous block ofstatus='open'. - Therefore no seek at all; the planner would either full-scan the index or the table.
Answer: . This index is useless for this query — you'd want a separate index leading with status. See Index selectivity & cardinality to decide if that's worth it.

Verify: leading column unconstrained → . ✔
Ex 6 — Covering vs non-covering (C6)
Steps
- Both seek on
(customer_id, status)— identical filter performance (). ✅ Why identical?INCLUDEcolumns are payload, not search keys; they don't change the seek. - IX-A leaf entries hold only the key + a row pointer. The query wants
amount, which isn't there. So for each matching row the DB does a table fetch (random I/O). For 500 matches → up to 500 random page reads. Why so costly? Table rows are scattered across pages (see Clustered vs non-clustered indexes); each is a separate disk trip. - IX-B stores
amountright in the leaf.amountis read straight from the index → 0 table fetches. This is an index-only scan.
Answer: IX-B is covering; table fetches drop from 500 to 0.
Verify: non-covering fetches ; covering fetches ; savings . ✔
Ex 7 — Sort + LIMIT served by the index, with a tie-break (C7)
Steps
- Seek
customer_id=42 AND status='open'→ lands on the block for that pair. . ✅ - Within that block, entries are already sorted by
created_at DESC(we built the index that way). Why does this matter?ORDER BY created_at DESCmatches the stored order exactly → no sort step needed. The DB just reads entries in storage order. - The tie. Three rows have identical
created_at. TheORDER BYalone doesn't say who wins. What breaks the tie? Sincecreated_atis the last key column, the tied rows sit next to each other in the leaf but in an order the query never pinned down — the engine returns them in whatever physical order they happen to occupy (often insertion/row-id order). This is a non-deterministic tie: the set of 10 rows is correct, but the order among ties can shift between runs or after a rebuild. Why care? If the boundary ofLIMIT 10falls inside a tie group, which tied row makes the cut can change run to run. Fix: add a deterministic tie-breaker, e.g.ORDER BY created_at DESC, order_id DESC, and putorder_idin the key so the index still supplies the full order. order_idis in the INCLUDE payload → index-only, no table fetch.
Answer: 0 sort operations, exactly 10 index entries read (plus the seek descent). Tied rows come out in physical order — not guaranteed stable unless you add a tie-breaker column.

Verify: entries read , sorts , fetches ; tie group size . ✔
Ex 8 — Degenerate & empty inputs (C8)
Steps
- (a) Missing value. Seek descends the tree to where
999999would be, finds the block empty, returns 0 rows immediately. Why still fast? A seek that lands on an empty range costs — same as a hit; it just reads 0 leaf entries. - (b)
NULLonstatus. In most enginesNULLis stored and sorted (typically as a distinct value at one end).status IS NULLseeks theNULLslot withincustomer_id=42. , works. Why care? Beginners assumeNULLbreaks indexes — it doesn't forIS NULL; it does break for= NULL(which is never true, matches nothing). - (c) Empty table. Index has 0 entries; the root is empty. Any seek returns 0 rows in . Why? Nothing to descend into — the degenerate limiting case of .
Answer: all three behave correctly; entries read = 0 for (a) and (c); (b) reads however many NULL-status rows customer 42 has.
Verify: (a) rows ; (c) rows ; = NULL matches (three-valued logic). ✔
Ex 9 — Real-world word problem (C9)
First, the recipe we'll apply, spelled out in full:
Steps (recipe: E → R → C)
- E — Equality columns first. We always filter one customer and one status:
customer_id = ?,status = 'paid'. Both equalities → they lead. Why? Each pins an exact slice (Ex 1 logic). - R — Range/sort column next. "20 most recent" =
ORDER BY created_at DESC LIMIT 20. Putcreated_at DESCright after the equalities. Why? Then results emerge pre-sorted (Ex 7 logic), no sort step. - C — Cover the output last. The dashboard shows
order_id, amount→ add them as INCLUDE payload. Why? Eliminates the table fetch (Ex 6 logic).
Answer:
CREATE INDEX ON orders (customer_id, status, created_at DESC)
INCLUDE (order_id, amount);Now the metric. Two equalities (customer_id, status) plus the sort column created_at used as the ordered read give a usable prefix of — same rule as Ex 3: the sort/range column is counted in . From that block we read only the first 20 entries, and with the INCLUDE payload there are 0 table fetches.
Verify: usable prefix (two equalities + the sort column), entries read , table fetches . ✔
Ex 10 — Exam twists (C10)
Steps
- (a)
OR. AnORbetween different columns is two separate conditions, not a single prefix. Why blocked?customer_id=42matches one block;status='open'matches scattered entries (Ex 5). No single contiguous range covers both. The planner may do an index-merge (two seeks unioned) or a full scan — but not one clean seek. Rewrite withUNIONof two indexable queries. - (b) Function on the column. The index stores raw
status, notLOWER(status). Why blocked? Seeking needs the stored sort order;LOWER(...)scrambles it. Fix: an expression index onLOWER(status), or store the value already lowercased. - (c) Reverse sort direction. Query wants
ASC, index isDESC. Why usually fine? B-trees are doubly linked, so the DB can read the block backwards — same block, reverse traversal, still no sort step. seek + backward scan.
Answer: (a) no clean seek (needs rewrite), (b) no seek (needs expression index), (c) seeks fine by reading backward.

Verify: (a) single-seek possible = false; (b) single-seek possible = false; (c) single-seek possible = true. ✔
Recall
Recall Match each example to its rule in one sentence
Ex 1 :::: full equality prefix → deepest possible seek (). Ex 2 :::: gap in the middle → seek stops at the gap; rest is filter. Ex 3 :::: range on the last used column → still one contiguous block, range counted in . Ex 4 :::: range on a middle column → later columns can't seek. Ex 5 :::: leading column unconstrained → , index unusable. Ex 6 :::: INCLUDE payload → same seek, zero table fetches. Ex 7 :::: matching sort order → no sort step; ties need an explicit tie-breaker. Ex 8 :::: empty/NULL/missing → seek still , returns 0 cleanly.
See also: Query planner / EXPLAIN to see which of these the optimizer actually chose, and Database normalization for why the column set exists in the first place.