Postgres and application sort order disagree

A customer list is sorted by name. Page one ends with “Zimmermann”. Page two starts with “Ärztekammer” — a row that should have been near the beginning — and one customer never appears at all. Refreshing changes which one is missing.

Two different orderings are in play. The database sorted by its own collation to slice the page; the application re-sorted that page with a locale-aware collator for display. Neither sort is wrong, and together they cannot produce a coherent list.

Two orderings across a paginated query The application asks the database for an ordered page, the database orders by its own collation, the application re-sorts the page with a locale collator, and the next page is fetched by offset against the database order — so a row can appear twice and another can be skipped entirely. How a collation mismatch skips a row App Database Page 1 Page 2 ORDER BY name LIMIT 20 byte order: Z before Ä re-sorted in the app OFFSET 20 — same byte order a row already shown, one never shown
The bug is not in either sort. It is in using two of them for one ordering.

Root cause: pagination is a promise about a total order

LIMIT and OFFSET mean “the first twenty rows of a specific ordering” and “the next twenty of the same ordering”. The promise only holds if there is exactly one ordering. When the application re-sorts each fetched page, the rows on the page are rearranged locally while the page boundaries were drawn by a different order — so rows near a boundary land on the wrong side, appear twice, or disappear.

The two orders differ because the defaults differ. A text column with no explicit collation orders by whatever the column inherited, which on many clusters is a byte-order collation where every uppercase letter precedes every lowercase one and accented letters sort after z. An Intl.Collator orders the way a reader of that language expects. Both are self-consistent; neither is a refinement of the other.

The failure is intermittent because it depends on where the boundary falls. A list whose page boundary happens to sit between two unambiguous names looks fine, and the same query with one more row inserted does not.

Five sources of sort order and how stable each is A query with no collate clause inherits whatever the column has, which may be unspecified. A server-wide default is stable only within one deployment. A named collation on the column is stable for every query against it. A per-query collate expression is stable for that query. An application-side collator is correct for the reader but applies only to the rows already fetched. Where an ordering can come from Orders by Stable across No COLLATE clause whatever the column inherited nothing Server default the server-wide locale one deployment Column collation a named ICU collation every query on it ORDER BY expression the named collation, per query that query only Application collator the reader locale one result set
Rows three and five are both defensible. Using them together, for the same list, is not.

Minimal reproducible example

-- The database's idea of order
SELECT name FROM customers ORDER BY name LIMIT 5;
--  Ärztekammer   ← or last, depending on the inherited collation
--  Zimmermann
// The application's idea of order, applied to the page it received
rows.sort(new Intl.Collator('de').compare);   // rearranges within the page only

Run the two together across three pages and rows will move between them.

The fix: choose one ordering and put it where the paging happens

If the database paginates — which is the usual and the scalable arrangement — the database must own the order, and the collation has to be named explicitly on the column so it is not inherited from a server-wide setting nobody chose.

-- PostgreSQL: an ICU collation matching the reader's language
CREATE COLLATION IF NOT EXISTS de_icu (provider = icu, locale = 'de-DE');
ALTER TABLE customers ALTER COLUMN name TYPE text COLLATE de_icu;

-- The index must be built for the same collation, or the sort cannot use it
CREATE INDEX customers_name_de ON customers (name COLLATE de_icu);

The application then renders the rows in the order it received them and does not sort at all.

// Display only. The order is the server's.
return rows.map((r) => <Row key={r.id} {...r} />);
Five rules for a stable localized ordering The collation is named on the column so no query inherits an unknown default. Sorting happens wherever pagination happens. A fetched page is never re-sorted, because sorting a subset produces a different global order. Keyset pagination replaces offsets so an inserted row cannot shift a page boundary. And a test asserts that both sides order an identical list identically. Making one ordering authoritative 1 Name the collation on the column never rely on the server default 2 Sort where you paginate the database, if the database pages 3 Never re-sort a fetched page a subset sort is a different order 4 Use keyset pagination, not OFFSET so a changed row cannot shift a page 5 Assert the two agree in CI same list, same order, both sides
Rule three is the one that gets broken by accident, usually by a helpful component.

Serving several reader locales from one table

The awkward case is a product whose readers expect different orderings of the same data — a German reader wanting ä with a, a Swedish reader wanting it after z.

Three arrangements work, with different costs.

Sort per query with a collate expression. ORDER BY name COLLATE sv_icu is correct and cannot use an index built for a different collation, so it sorts the whole matching set each time. Acceptable for small tables and filtered result sets; expensive for a large unfiltered list.

Index per locale. One index per collation you support, and the query names the matching one. Fast, and it costs write throughput and storage proportional to the number of locales — reasonable at three, unreasonable at thirty.

Sort in the application, and page in the application. Fetch the whole set, collate it for the reader, and slice locally. Only viable when the set is genuinely small, and it is the arrangement people reach for accidentally when they add a client-side sort to a server-paginated list.

The decision is usually made by data size. What matters is that it is a decision: the failure at the top of this page comes from two of these being active at once.

Why OFFSET makes it worse

Even with a single ordering, offset pagination is fragile under writes. A row inserted before the current offset shifts every subsequent page by one, so a reader paging through a changing list sees a row twice or misses one — the same symptom as a collation mismatch, with a different cause.

Keyset pagination removes it by paging on the ordering key rather than on a count:

-- Page after the last row seen, in the same collation
SELECT name, id FROM customers
WHERE (name COLLATE de_icu, id) > ($1 COLLATE de_icu, $2)
ORDER BY name COLLATE de_icu, id
LIMIT 20;

The tie-break on id matters: names are not unique, and two rows with the same name would otherwise make the boundary ambiguous. Adding a unique tie-break to every ordering is worth doing regardless of collation.

Spotting it before a customer does

The symptom reaches support as “a record disappeared”, which is why this bug survives so long: nobody reports a sorting problem, they report missing data.

Three signals distinguish it from an actual data loss. The row reappears on a different page, which no deletion explains. The affected rows cluster around page boundaries rather than being distributed randomly. And the set of affected rows changes when the page size changes, because a different page size draws the boundaries in different places — which is the single most diagnostic test available and takes one query parameter to run.

A cheap detector belongs in the test suite regardless. Paging through a fixture list of a few hundred names, accumulating the ids seen, and asserting that the result contains every id exactly once catches every variant of this problem — collation mismatch, offset instability, and a missing tie-break — without needing to know which one it is.

The same check is worth running against a staging database with real data occasionally, because production data contains name shapes no fixture does: names with leading spaces, names in scripts nobody planned for, and the single-character names that end up adjacent under one collation and far apart under another.

Verification

test('database and application agree on order', async () => {
  const fromDb = await db.query(
    'SELECT name FROM customers ORDER BY name COLLATE de_icu'
  );
  const inApp = [...fromDb.rows.map((r) => r.name)]
    .sort(new Intl.Collator('de').compare);

  expect(fromDb.rows.map((r) => r.name)).toEqual(inApp);
});

If that test fails, the two collations genuinely differ and no amount of pagination logic will reconcile them — pick one and delete the other. Running it against a fixture containing accented, uppercase and numeric-suffixed names is what makes it meaningful; a list of plain lowercase ASCII names passes under every collation.

-- Which collation is this column actually using?
SELECT attname, collname FROM pg_attribute a
JOIN pg_collation c ON a.attcollation = c.oid
WHERE attrelid = 'customers'::regclass;

When to escalate

If the orders agree and pagination still skips rows, the cause is concurrent writes rather than collation — move to keyset pagination.

If an ICU collation is unavailable, the database may have been built without ICU support. That is a server-level constraint, and the fallback is to sort in the application and page there too, which is a size question rather than a correctness one.

If ordering is correct but slow, check whether the query is using an index built for the same collation. A collate expression that does not match any index turns an indexed scan into a full sort, which is the usual explanation for a locale-aware ordering being an order of magnitude slower — the performance note in Unicode text handling and collation.

FAQ

Can I just sort everything in the application?

Only if you also paginate there, and only if the set is small enough to fetch whole. The moment the database is slicing pages, it owns the order.

Does changing a column’s collation require a rebuild?

Yes. Indexes are ordered structures built for a specific collation, so changing it invalidates them and they must be rebuilt. Plan it as a migration with a maintenance window rather than as a settings change.

Is utf8mb4_0900_ai_ci in MySQL good enough?

It is a reasonable general default: Unicode 9 based, accent- and case-insensitive. It is not locale-tailored, so languages with their own ordering — Swedish, Turkish, Czech — get the generic order. MySQL offers locale-specific variants where that matters.

What about sorting in a search engine?

Search engines have their own collation configuration, which is a third ordering to keep aligned. If results are ordered by the engine and rendered by the application, the same rule applies: whoever paginates owns the order.

Part of Unicode Text Handling & Collation.