Sorting: Determinism or Drift
An ORDER BY in the contract is two promises: that the ordering is affordable, and that it is deterministic. Skip the tiebreaker and pagination corrupts; allowlist nothing and every column is an index you owe.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Non-deterministic order is a pagination bug in disguise
SQL makes a promise weaker than intuition expects: ORDER BY created_at DESC constrains *only* the relative order of rows with different created_at values. Fifty orders imported in the same batch share a timestamp, and the database may return them in any order — a different order per execution, because plans, parallelism and page layout all legally influence it. Unpaginated, this is invisible. Paginated, it is corruption: page 1 ends inside the tie with rows {A, B}, the next request re-executes the query, the tie shuffles, and page 2 begins with B again while C was never returned at all. No writes occurred; the API simply never promised a total order, and pagination silently required one.
The fix costs six characters: a unique tiebreaker. ORDER BY created_at DESC, id DESC makes every comparison decidable, every ordering total, every page boundary a fixed point. The rule is absolute in a way little in API design is: every ordering the API exposes ends in a unique key, and the tiebreaker is part of the contract (and part of the cursor — a keyset cursor is built from exactly these columns, see Cursor Pagination: An Opaque Bookmark, Not a Position). This bug is a unit-test escape artist — test fixtures rarely produce ties, production batch imports always do — which is why the rule must live in the contract and the code review checklist, not in the test suite.
Determinism has a second, softer half: stated defaults. An endpoint with no documented default order still returns rows in *some* order, and clients bind to it — the classic case is "the API returns insertion order" (actually: whatever the storage engine did) breaking the day a migration rewrites the table (see What an API Contract Actually Is on accidental promises). Every list endpoint documents its default ordering, and the default obeys the same tiebreaker rule as everything else.
1-- 50 rows share created_at = '2026-07-01 09:00:00'2SELECT * FROM orders3ORDER BY created_at DESC4LIMIT 50 OFFSET 0; -- ends mid-tie: … A, B5 6SELECT * FROM orders7ORDER BY created_at DESC8LIMIT 50 OFFSET 50; -- tie re-shuffles between calls:9 -- begins B, … (A repeated? C never seen?)10-- no concurrent writes needed; the order was11-- never total, so "the next 50" was never defined1SELECT * FROM orders2ORDER BY created_at DESC, id DESC3LIMIT 50 OFFSET 0;4 5-- ties broken identically on every execution;6-- page boundaries are stable, and the same7-- (created_at, id) pair drives the keyset cursor:8WHERE (created_at, id) < (:last_ts, :last_id)9ORDER BY created_at DESC, id DESC10LIMIT 50;The bad query is not wrong SQL — it is an incomplete contract. "The next 50" is only meaningful under a total order, and pagination asks for "the next 50" on every request. The tiebreaker turns an ordering that was merely *plausible* into one that is *defined*.
Sortable fields are an allowlist with the same bill as filters
A sortable column is a promise that the database can produce the collection in that order affordably — which at scale means an index whose order matches, because the alternative is sorting the whole result set on every request just to return its first page. ?sort=-total on 40 million orders without an index on total is a top-N-over-everything operation per call. Like Filtering: An Allowlist With an Index Bill, the honest design is a short allowlist: each entry in ?sort= maps to an index decision someone made on purpose, and unknown sort fields are rejected with a field-level error, not served with a filesort.
The bill compounds where sorting meets filtering, because the index that serves status=paid ORDER BY created_at DESC is the composite (status, created_at) — neither single-column index does the job alone. Every (filter-combination × sort-option) cell you promise is a query plan; leftmost-prefix and range-then-sort rules decide which cells one composite index covers. This is why mature APIs offer two or three sort options on their busiest collections, not fifteen: each option multiplies across the filter surface, and the write amplification of the index family is the real constraint (see API Performance: The Levers You Actually Own for where this cost surfaces).
Two semantic clauses complete the contract. Direction: pick one syntax (?sort=-created_at or ?sort=created_at&order=desc), use it everywhere (One Vocabulary: Naming and Consistency — clients write one sort-param builder). Edge semantics: where do NULLs sort, and how is text compared? ORDER BY discount with NULLs, or case- and locale-sensitive name sorting, differ across databases and even collation configs — if a consumer can observe it at a page boundary, it is contract, and one documented sentence beats a cross-database surprise during your next storage migration.
- Each sortable field = an index-order promise; reject unknown sort fields rather than filesorting the table.
- Filter × sort combinations multiply:
(status, created_at)composite servesstatus=… ORDER BY created_at, no pair of single-column indexes does. - Offer few orderings on big collections — each one is an index family with a write bill.
- One direction syntax API-wide; documented NULL placement and text-comparison behavior wherever observable.
- The default ordering is a contract clause, stated and tiebroken like every other.
Stability across requests, and orderings that are not columns
Deterministic ordering makes page boundaries stable; it does not freeze the world. Sorting by a *mutable* field (?sort=-updated_at, or by status) means rows legitimately teleport across pages as they change — a row updated mid-traversal moves to the front the traverser already passed, and a paginating client never sees it. That is not a bug in the ordering; it is a property the contract should name, and a reason completeness-critical consumers should traverse by an immutable ordering (created_at, id) even when the UI displays something else (see Consistency as a Contract Clause). Immutable-key traversal is also what makes long-running exports coherent.
Some orderings are computed rather than stored — "most relevant", "recommended", "trending". These break the machinery quietly: there may be no index to walk, scores can change between page requests (drift again, by another door), and a keyset cursor needs the score *in* the key to seek. The honest options are to materialize the score so it behaves like a column, snapshot the ranked result per traversal (a search-style scroll context), or declare the ordering shallow — top-100 only, no deep pagination, which for "trending" is usually the true requirement anyway. Relevance ordering is enough of its own contract that it gets its own lesson (see Search Is a Different Contract Than Filtering).
Key points
- ORDER BY on a non-unique column is not a total order; ties re-shuffle between executions, and paginated page boundaries turn that into repeats and silent gaps with zero concurrent writes.
- Every exposed ordering ends in a unique tiebreaker — it is six characters, it is the fix, and it is the same key the cursor is built from.
- Undocumented default order is an accidental promise; state the default and tiebreak it like everything else.
- Sortable fields are an allowlist with an index bill that multiplies across filter combinations — few orderings, each deliberately indexed.
- Sorting by mutable fields makes rows teleport across pages mid-traversal; completeness-critical consumers traverse by immutable keys.
- Computed orderings (relevance, trending) need materialization, snapshots, or an explicit shallow-depth contract — column machinery does not carry them.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships
?sort=accepting any column,ORDER BYexactly what the client sent — no tiebreaker, no allowlist. - 2Test suite → API: passes; fixtures have unique timestamps, small tables, no deep pages.
- 3Production → batch import: creates 10,000 rows sharing one timestamp; paginated exports through the tie repeat and drop rows run-to-run.
- 4Consumer → analytics: a partner's
?sort=lifetime_value(unindexed) dashboard refreshes every minute; each refresh sorts the table. - 5Provider → incident: database CPU pins during business hours; the fix now requires both an index program and a breaking sort-allowlist deprecation.
- Sync and export consumers get different row sets on identical queries — non-reproducible bugs that burn support weeks because every individual response looks valid.
- Unindexed sort options let any consumer schedule full-table sorts against production; the blast radius is database-wide latency.
- Clients bound to an accidental default order break on migrations, storage engine changes and plan changes — deploys that "changed nothing".
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Append a unique tiebreaker to every ordering — including the default — and enforce it in the query layer so no handler can emit an untiebroken ORDER BY.
- • Allowlist sortable fields per endpoint; map each (filter, sort) combination you document to a composite index that serves it, and reject the rest with a field-level error.
- • Document default order, direction syntax, NULL placement and text-comparison semantics once, API-wide.
- • Give completeness-critical consumers an immutable-key traversal path, whatever mutable orderings the UI offers.
- • Log sort-option usage per consumer — the evidence for adding an index, deprecating an option, and attributing the expensive queries.
- • Watch the slow-query log and plan output for filesorts originating in API endpoints; each is a sort promise the index family is not keeping.
- • Alert on page-boundary anomalies from your own SDK telemetry where available (duplicate ids across consecutive pages) — the tie bug seen from the client side.
- • New sort options are additive: index first, then document, then release — in that order, because the moment it is documented someone automates it.
- • Removing a sort option or changing the default ordering is behavioral breakage clients feel immediately; both need telemetry and a deprecation window (see [[deprecation]]).
- • Changing tiebreaker or NULL semantics shifts page boundaries under existing traversals — version it like the breaking change it is, or absorb it where cursors keep old traversals coherent.
- • A tiebroken composite ordering makes the index slightly wider and the promise slightly stiffer — the tiebreaker column is now load-bearing schema.
- • Small sort allowlists push "sort by anything" workloads toward an analytics path you must build, or toward the client sorting a full export it must now fetch.
- • Documenting NULL and collation semantics binds you to them across database migrations; leaving them unstated leaves you free — and your consumers exposed. The contract's stiffness is the consumer's safety.