Lesson 5. Sorting and Collation
Mission link: Two systems that sort the same rows differently, and a paginated list that repeats a row, are both this lesson. Ordering looks like the simplest clause in SQL and it carries the most hidden configuration.
Primary source: PostgreSQL, 7.5 Sorting Rows (ORDER BY)
Prerequisites: Lesson 4
Warm-up
- ▢ Which of
UNIONandUNION ALLshould be the default, and why?
Check
UNION ALL. UNION sorts or hashes the whole result to remove duplicates, which is pure cost when the sides are disjoint.
- ▢ Why can a subquery's
ORDER BYbe discarded by the outer query?
Check
Because ordering is applied to a result and is not a property of a relation. Only a top-level ORDER BY is a promise.
Know this
ORDER BY sorts the output. It runs after SELECT, so it can use output aliases and column positions:
SELECT amount * 0.2 AS tax FROM orders ORDER BY tax DESC;
SELECT id, amount FROM orders ORDER BY 2 DESC; -- by position, avoid
Ordering by position is legal and fragile: adding a column to the select list changes the meaning silently. Name the column.
A tie is not an order
SELECT id, customer_id FROM orders ORDER BY customer_id LIMIT 10;
Rows with the same customer_id may come back in any order, and that order can change between runs. So a LIMIT over a non-unique sort key returns a non-deterministic slice, which is the same problem as lesson 2's unordered LIMIT, just harder to notice because the query does have an ORDER BY.
Always end the sort key with something unique:
ORDER BY customer_id, id
This is also what makes pagination correct. With OFFSET, a tie that reshuffles between two page requests can show the same row twice and skip another entirely, and no amount of retrying fixes it because nothing is wrong from the engine's point of view.
Both requests returned the same five rows, and both took their page from the same positions. The repeat and the gap are produced entirely by the order changing in between.
NULLs sort somewhere, and the standard does not say where
In PostgreSQL, NULLs are treated as larger than any value: last under ASC, first under DESC. The standard leaves it to the engine, so a query that must behave the same on two engines states it:
ORDER BY shipped_at DESC NULLS LAST
That matters most for "most recent first" reports, where the default DESC puts every unshipped order at the top.
Text ordering is a collation, not a rule of SQL
A collation decides how text compares: which letters sort together, whether case matters, how accents are handled. It is configured per database, and can be set per column or per expression:
SELECT email FROM customers ORDER BY email COLLATE "C";
Two collations worth knowing about:
C, sometimes calledPOSIX, compares raw bytes. It is fast, stable across systems, and sortsZbeforeabecause uppercase letters have lower byte values.- A language collation, such as
en_USor an ICU locale, sorts the way a person in that locale expects, which usually means ignoring case for ordering purposes and applying locale-specific rules to accented letters.
Three practical consequences (collation support):
- The same query on two databases with different collations returns rows in a different order, and neither is wrong.
- Comparison follows the collation too, so whether
'a' = 'A'depends on it. Most default collations are case-sensitive for equality even when their sort order looks case-insensitive. - An index is built in a specific collation. A query that sorts or compares in a different collation cannot use it, which is a stage 6 concern and a real cause of a query that got slower after a locale change.
For case-insensitive matching, the portable spelling is lower(email) = lower($1), and the PostgreSQL-specific answers are the citext type and a case-insensitive ICU collation. Note that lower() on a column prevents an ordinary index from being used unless the index is built on the same expression.
Sorting is not free
A sort that fits in the engine's working memory is fast, and one that does not spills to disk. So ORDER BY on a large result is often the most expensive part of a query, and an index in the right order can remove the sort entirely. Stage 6 is where that becomes a tool; for now, notice that ORDER BY has a cost at all.
Practice
-
▢ This list occasionally shows the same order twice across two pages. Explain and fix.
SELECT id, customer_id FROM orders ORDER BY customer_id LIMIT 20 OFFSET 20;
Hint
Ask what the engine is allowed to do with two rows that have the same customer_id.
Check
Rows sharing a customer_id are tied, and a tie has no defined order, so the engine may place them differently on each execution. A row that was on page 1 can appear on page 2, and another can be missed.
Fix by making the sort key unique: ORDER BY customer_id, id. That is necessary for any paginated query, and stage 6 will replace OFFSET itself with a keyset condition for the separate performance reason.
-
▢ Predict where the unshipped orders appear, and write the version a "most recent first" report actually wants.
SELECT id, shipped_at FROM orders ORDER BY shipped_at DESC;
Check
In PostgreSQL they appear first, because NULLs sort as though larger than any value and the order is descending.
SELECT id, shipped_at FROM orders ORDER BY shipped_at DESC NULLS LAST, id DESC;
Being explicit also makes the query behave the same on an engine whose default is the other way round.
-
▢ The same query returns a different order on a colleague's machine. Which explanation is most likely?
- a) One of the databases has corrupt data
- b) The two databases use different collations
- c) The query is missing a
WHEREclause - d) One machine has more memory available
Check
b) The two databases use different collations.
Text ordering is defined by the collation, which is set when the database is created, so the same rows legitimately sort differently. Option d can change whether a sort spills to disk and not its result, unless the sort key has ties, in which case the order was never defined to begin with.
- ▢ A login lookup must treat
Ada@Example.comandada@example.comas the same address. Give a portable spelling and name its cost.
Check
WHERE lower(email) = lower($1)
The cost is that a plain index on email cannot serve this query, because the indexed values are not what is being compared. The fix is an index on the expression, CREATE INDEX ON customers (lower(email)), which stage 6 covers.
The other options are engine-specific: a case-insensitive collation on the column, or PostgreSQL's citext. Both are cleaner to read and move the decision into the schema, where it is easier to apply consistently and harder to notice.
- ▢ Why is
ORDER BY 2legal, and why would you still not write it?
Check
It is legal because ORDER BY runs after the select list exists, so it can refer to output columns by position as well as by name.
Not worth writing because the meaning depends on the shape of the select list. Insert a column at the front and every positional sort key now refers to something else, with no error, and a reviewer cannot see it in the diff, because the ORDER BY clause itself did not change.
Real-world reps
- [ ] Sort a text column with
COLLATE "C"and then with the database default, using values that differ in case, and compare the two orders. - [ ] Build a table with deliberate ties, page through it with
LIMITandOFFSET, and try to produce a duplicated row. Then add a unique tie-breaker and try again. - [ ] Tomorrow: find a paginated query in code you know. Check whether its sort key is unique. Most are not.
Going further
- 7.5 Sorting Rows:
ASC,DESC,NULLS FIRSTandNULLS LAST - 23.2 Collation Support: how collations are chosen, and what they affect besides ordering
- 7.6 LIMIT and OFFSET: the documentation's own warning about ties
- Evaluation order of a SELECT: where
ORDER BYsits, and what it can see - Resources
Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.
If the lesson itself is unclear rather than the material, that is a defect: open an issue.