Why can an index on events(created_at, id) not serve ORDER BY created_at DESC, id ASC without sorting?
answer
- think about which way the scan can walk
- there are exactly two free sequences
- reversing must be all keys or none
- tie-breakers are where this usually bites
basics
~20 sAn index scan yields key order forwards or its exact reverse backwards, nothing else. The exact reverse of (created_at ASC, id ASC) is (created_at DESC, id DESC); a list that flips one key but not the other is neither, so a sort is added.
solid answer
~40 sIndex-supplied ordering is all-or-nothing on direction. Walking the leaf level forwards gives `created_at ASC, id ASC`; walking it backwards gives `created_at DESC, id DESC`. Those are the only two sequences that come for free. `ORDER BY created_at DESC, id ASC` mixes them: it wants the outer key reversed and the inner key not, which no single walk of that index produces, so the engine sorts. Your options as the query author are to reverse **every** key so the list becomes the exact inverse, to accept the sort, or to have an index whose keys declare the mixed directions (`(created_at DESC, id ASC)`), which several engines support. Watch the ties: flipping the tie-breaker changes which rows come back first when a row limit is attached.
code
sql · 7 lines-- CREATE INDEX ix_events_at_id ON events (created_at, id);
-- backward walk of the index, stops after 20 entries
SELECT * FROM events ORDER BY created_at DESC, id DESC FETCH FIRST 20 ROWS ONLY;
-- neither key order nor its exact reverse: forces a sort of every matching row
SELECT * FROM events ORDER BY created_at DESC, id ASC FETCH FIRST 20 ROWS ONLY;go deeper
Recall that a descending sort on a single indexed column is free, and that the trouble starts when a second ordering key points the other way.
Explain the mechanism: the leaf chain is read forwards or backwards, so exactly two sequences are free, and a mixed list is neither. Name the tie-breaker case where developers hit it.
Recognise the signature — small row limit, indexed columns, sort node in the plan — and decide between flipping the whole list, accepting the sort, and requesting an index with declared directions, including what the flip does to tie order under a limit.
Treat it as an interface question: if a paged API contract fixes a mixed ordering, you have committed to either a sort or a dedicated index forever. Pin down ordering contracts before they harden into schema obligations.
## How an index scan produces order The leaf level of a B-tree index is a sorted, doubly linked chain of entries. Because it is doubly linked, an engine can traverse it in either direction, and that is the whole source of "free" ordering. Traversing forwards emits entries in declared key order. Traversing backwards emits them in the exact mirror image of that order. There is no third traversal that flips some keys and leaves others alone — the chain has one sequence, and you read it one way or the other. ## The all-or-nothing direction rule For an index declared on `(created_at, id)` — both keys ascending, the default — the two available sequences are: ```sql -- CREATE INDEX ix_events_at_id ON events (created_at, id); ORDER BY created_at ASC, id ASC -- forward walk, free ORDER BY created_at DESC, id DESC -- backward walk, free ORDER BY created_at DESC, id ASC -- neither: sort ORDER BY created_at ASC, id DESC -- neither: sort ``` Generalised: an `ORDER BY` list can be index-supplied only if every key's direction equals the corresponding index key's direction, or every key's direction is the opposite of it. Mixing is what costs you the sort. With a single ordering key the rule is invisible, because one key is trivially either "all same" or "all flipped" — which is why so many developers first meet the trap when they add a tie-breaker to a descending sort. ## Why it bites exactly there The canonical shape is a newest-first feed with a stable tie-break: ```sql SELECT * FROM events ORDER BY created_at DESC, id ASC FETCH FIRST 20 ROWS ONLY; ``` The intent is reasonable: newest first, and among events sharing a timestamp, the lower id first. But it silently converts a twenty-entry backward walk into a full sort of every row the filter admits, and the row limit no longer bounds the work. Writing `ORDER BY created_at DESC, id DESC` restores the backward walk. The result differs only in the sequence of rows that share a `created_at` value — usually a detail nobody has an opinion about, and worth trading for the sort. There is one case where you cannot simply flip: when the tie-break direction is load-bearing. If a client pages by remembering the last `(created_at, id)` it saw and asking for rows after it, the comparison predicate and the `ORDER BY` directions must agree, so you cannot flip one without reworking the other. ## What you can and cannot fix in the query text Tricks that work in a `WHERE` clause do not transfer here. Negating a numeric column (`ORDER BY created_at DESC, -id`) is an expression, and an expression discards the index order just as surely as the mixed direction did — you have traded one sort for another. There is no portable rewrite that turns a genuinely mixed ordering into an index walk. What exists instead is on the schema side: most engines let `CREATE INDEX` declare a direction per key, so an index on `(created_at DESC, id ASC)` makes the mixed list a forward walk (and `(created_at ASC, id DESC)` its free reverse). Choosing to add such an index is index-design work with its own write cost, so as the query author your first move is to check whether the fully reversed list is acceptable. ## NULL placement rides along Where NULLs sit relative to real values is part of the physical order too. Engines differ on the default — and in some, the default placement itself flips between `ASC` and `DESC`. If you write an explicit `NULLS FIRST` or `NULLS LAST` that disagrees with how the index physically stores them, you can reintroduce the sort even when the columns and directions line up. If the ordering columns are nullable and you have specified placement explicitly, verify with the plan rather than reasoning it out. ## Diagnosing it The symptom is distinctive: a query with a small row limit whose cost is proportional to the table, on columns that are indexed, and a plan showing a sort node above the scan. When you see that, read the `ORDER BY` directions against the index's declared directions before suspecting anything more exotic — mixed directions and an expression on an ordering key are the two most common causes of a sort that "should not be there".
- If you flip the tie-breaker to ORDER BY created_at DESC, id DESC, what actually changes in the result?Only the sequence of rows that share a `created_at` value: among ties, the highest id now comes first. The set of rows is unchanged for an unlimited query. With a row limit it can change *which* rows appear, because a different tie member falls inside the cut. If ties are rare or the tie order is not contractual, this is a cheap trade for losing the sort.
- Would ORDER BY created_at DESC, -id restore the index walk on a numeric id?No. `-id` is an expression, and the index stores the order of `id`, not of `-id`, so the engine cannot conclude anything about the ordering from the index. You have replaced a mixed-direction sort with an expression sort. Negation tricks belong to expression indexes, not to query rewrites against an existing plain index.
- With a single ordering key, is a descending index ever needed to avoid a sort?No. One key means the requested order is either the index's order or its exact reverse, and both are free from a forward or backward walk. Per-key direction in `CREATE INDEX` earns its keep only for multi-key orderings that genuinely mix directions.
A sorted deck can be dealt from the top or from the bottom. Dealing from the bottom reverses everything at once — you cannot ask for the suits reversed but the ranks left alone without re-sorting the deck.
saying these in an interview costs you the question
- Says a descending sort always requires a descending index
- Thinks any list of indexed columns can be index-ordered regardless of direction
- Suggests negating a column in ORDER BY to fix direction
- Assumes flipping the tie-breaker cannot change which rows a row limit returns
- Ignores NULL placement as part of the physical order