skip to content

Why can an index on last_name not supply the order for ORDER BY LOWER(last_name)?

level: middleimportance: should knowfreq 40%

answer

  1. an index stores one particular ordering
  2. the engine cannot assume functions keep sequence
  3. some wrappers can simply be deleted
  4. case folding really does reorder values
  5. watch for expressions hidden in a view or alias

basics

~10 s

The index stores the sort order of last_name, not of LOWER(last_name), and lowercasing can reorder values under a case-sensitive collation. Since the engine cannot assume a function preserves order, it sorts the result instead.

solid answer

~40 s

An index is an ordered list of *column values*. It tells the engine the sequence of `last_name`, and nothing about the sequence of any function applied to it. Because a function may reorder its inputs arbitrarily — under a case-sensitive collation `'Zeta'` sorts before `'alpha'`, but `LOWER` swaps them — the engine cannot reuse the index order and adds a sort. The practical distinction is whether your expression is order-preserving. `total_cents / 100.0`, `col + 1` and `CAST(ts AS DATE)` never move a value ahead of a smaller one, so `ORDER BY` the bare column produces an ordering that satisfies the original request and is index-friendly. `LOWER(name)`, `COALESCE(shipped_at, created_at)` and `CASE` expressions genuinely reorder rows; those need a case-insensitive collation, an index on the expression, or an accepted sort.

code

sql · 7 lines
sql
-- index on invoices(total_cents)

-- expression around the ordering key: forces a sort of every matching row
SELECT * FROM invoices ORDER BY total_cents / 100.0 DESC FETCH FIRST 20 ROWS ONLY;

-- dividing by a positive constant preserves order, so the wrapper can go
SELECT * FROM invoices ORDER BY total_cents DESC FETCH FIRST 20 ROWS ONLY;

go deeper

for a junior

Remember that an index knows the order of the raw column values only. Wrapping the ordering column in a function means the database has to sort.

for a middle

Explain why the optimiser cannot assume a function preserves order, and separate order-preserving wrappers you can delete from ones like LOWER or COALESCE that you cannot.

for a senior

Spot expressions you did not type — hidden in a view, a select-list alias, or an implicit conversion — and weigh the collation change, generated column, and accepted sort against each other for the real data volume.

for a principal

Decide the policy: which orderings the product commits to, and whether case-insensitive presentation ordering should be solved once in the column's collation rather than repeatedly in query text across many teams.

## An index records one specific ordering A B-tree index on `last_name` is a sorted list of the stored `last_name` values (in the column's collation) plus pointers to rows. Reading it yields exactly one ordering: by `last_name`. When a query asks for rows ordered by `LOWER(last_name)`, that is a different ordering of the same rows, and the index holds no information about it. The optimiser will not — and cannot safely — assume that applying a function leaves the sequence intact, so it plans a sort. This is not a limitation of a particular engine; it follows from what the structure stores. The consequence for the query author is sharp: an expression around an ordering key silently converts a cheap ordered walk into a blocking sort, which matters most when a row limit sits on top, because that limit then bounds the result without bounding the work. ## Order-preserving transforms you can strip Many expressions people write are strictly increasing over the column's domain. If `f` never moves a value ahead of a smaller one, then ordering by the bare column already satisfies ordering by `f(col)`, and you can simply delete the wrapper: ```sql -- index on invoices(total_cents) SELECT * FROM invoices ORDER BY total_cents / 100.0 DESC FETCH FIRST 20 ROWS ONLY; -- sorts SELECT * FROM invoices ORDER BY total_cents DESC FETCH FIRST 20 ROWS ONLY; -- index walk ``` Dividing by a positive constant, adding a constant, and multiplying by a positive constant are all order-preserving. So is truncating a timestamp to a date: ordering by the full timestamp is a *refinement* of ordering by its date part, and since rows sharing a date may legally come back in any sequence, the refined ordering is still a correct answer to the original request. Ordering by an alias of a computed select-list column has the same trap and the same cure — check what the alias expands to. One related rewrite is worth naming: `ORDER BY -price` expresses descending order through arithmetic. Replace it with `ORDER BY price DESC`, which the index can serve. Be careful if the column is nullable, because engines that place NULLs at opposite ends for `ASC` and `DESC` will move the NULL rows; add an explicit `NULLS FIRST` or `NULLS LAST` if their position matters — and then confirm you have not reintroduced a sort by asking for a placement the index does not physically have. ## Transforms you cannot strip Other expressions genuinely reorder rows, and no rewriting of the `ORDER BY` will make the plain column index apply: - **`LOWER(name)` / `UPPER(name)`** under a case-sensitive collation. Case-sensitive orderings typically group by case, so `'Zeta' < 'alpha'` while `LOWER` makes `'alpha' < 'zeta'`. Different sequence, different ordering. - **`COALESCE(shipped_at, created_at)`** — the value being ordered comes from two columns, so no single-column index describes it. - **`CASE WHEN status = 'URGENT' THEN 0 ELSE 1 END`** — a business-priority ordering that exists nowhere in the stored data. - **String manipulation** such as `SUBSTRING(code FROM 4)`, which reorders on a suffix the index knows nothing about. For these the options are: change the column's collation so the plain ordering *is* the one you want (a schema decision with wide consequences); persist the computed value, either as a generated column or by having the schema owner create an index on the expression; or accept the sort because the result set is small enough for it not to matter. The middle option is index-design work — as the query author your contribution is to state precisely which expression must be ordered. ## Watch for expressions you did not write An expression can arrive without being typed by you. Ordering by a select-list alias that names a computed column is one route. A comparison across mismatched types is another: if the engine has to convert a value to compare or order it, it is ordering something other than the stored column. Sorting on a column of a derived table or view whose definition wraps the column in a function is a third, and the most easily missed, because the query text you are reading looks perfectly bare. ## The habit to build When a query has a row limit and feels slower than the number of returned rows justifies, read the `ORDER BY` list and ask of each key: *is this the literal name of a stored column?* If not, decide whether the expression is order-preserving — if it is, delete it; if it is not, you are choosing between a schema change and a sort.

  • Why is ORDER BY CAST(created_at AS DATE), then, safe to replace with ORDER BY created_at?
    Because truncating a timestamp to its date never moves a later timestamp ahead of an earlier one. Ordering by the full timestamp is a stricter ordering that still satisfies ordering by the date part — rows sharing a date are simply returned in a specific sequence rather than an arbitrary one, which the original request permits.
  • You must order by LOWER(last_name) and cannot change the query. What are the options?
    Three, all outside the query text: give the column a case-insensitive collation so the plain ordering already matches; store the folded value in a generated column and order by that; or have an index created on the expression itself so the engine has the folded order available. Otherwise accept the sort and keep the input small with a selective filter.
  • Is ORDER BY -price a good way to sort descending?
    No. It is an expression, so an index on `price` cannot supply the order, and it can also relocate NULL rows because engines may default NULLs to opposite ends for ASC and DESC. Write `ORDER BY price DESC`, adding an explicit `NULLS FIRST`/`NULLS LAST` if the placement of NULLs is part of the requirement.

saying these in an interview costs you the question

  • Assumes an index on the column also orders any function of it
  • Says the optimiser will simplify the expression away
  • Claims LOWER cannot change the ordering of strings
  • Sorts with -col instead of DESC
  • Misses that a view or alias is hiding the expression

context