Skip to main content

Command Palette

Search for a command to run...

Arabic sorting that stays correct in production

A practical way to separate display order, matching, identity, indexes and pagination before collation choices become data bugs.

Updated
8 min readView as Markdown
Arabic sorting that stays correct in production
H
I have lead the Engineering for multiple startups in UAE. I also have my own agency qualascend.com.

A customer list sorted with array.sort() may look harmless in staging. In production, the same shortcut can scatter Arabic names by code point, put "محمود 10" before "محمود 2", disagree with the database, and make the next page repeat or skip records.

The fix is not an Arabic alphabet array. It is a small set of explicit policies.

Unicode's collation algorithm compares text through weighted levels and allows locale-specific tailoring. CLDR supplies the locale data and settings used by implementations such as ICU. That machinery is designed for ordering human language. It should not quietly become the definition of account identity, uniqueness, search relevance and API pagination too.

Start by naming the operation

"Compare these two strings" is incomplete. The product has to say why it is comparing them.

Operation Product question Sensible owner
Display sorting In what order should a user see these labels? Locale-aware collator
Equality Should these values count as the same for this feature? Feature-specific rule
Search Is this candidate a useful match for the query? Search pipeline or search collator
Database constraint May both values exist? Schema and business identity policy
Cursor pagination Which record comes next, without ambiguity? Database order plus a unique tie-breaker

This separation matters because collation can judge distinct strings equal at the selected strength. It can also change as language data and implementations change. Unicode is explicit that collation order is not fixed, and that stable sorting is a property of the sorting algorithm, not the comparison mechanism.

Five production text operations mapped to separate policies: display sort, equality, search, constraints and cursor pagination.

One text field can participate in five operations. Give each operation its own contract instead of inheriting one global collation. Sources: Unicode UTS #10 v17.0.0, CLDR v48.2, ECMA-402, PostgreSQL 18, MySQL 8.4 and the ICU User Guide. Graphic: SultanByte editorial artwork.

Sort Arabic labels in the interface

JavaScript already exposes the right primitive. Intl.Collator performs language-sensitive comparison, and its compare function can be passed to sort. MDN documents the API and its locale-dependent results.

const rows = [
  { id: 5, name: "محمود 10" },
  { id: 2, name: "أحمد" },
  { id: 4, name: "محمود 2" },
  { id: 1, name: "إبراهيم" },
  { id: 3, name: "محمد" },
];

const collator = new Intl.Collator("ar", {
  usage: "sort",
  sensitivity: "variant",
  numeric: true,
});

const sorted = [...rows].sort(
  (a, b) => collator.compare(a.name, b.name) || a.id - b.id,
);

On Node.js 22.23.1 with its installed internationalization data, that produced:

إبراهيم
أحمد
محمد
محمود 2
محمود 10

The important parts are the explicit locale, usage: "sort", and the final comparison by id. Numeric collation treats a run of decimal digits by numeric value, which is why 2 precedes 10; CLDR defines that behavior at the primary comparison level.

Do not snapshot that exact list and call it a universal Arabic order. ECMA-402 says the underlying locale data is implementation-dependent and may vary over time. Inspect collator.resolvedOptions() in diagnostics, and run a fixture of product-relevant names against every supported runtime during upgrades.

If the server produces the list, let the server own the order. Re-sorting a page in the browser can make page one look polished while the database still decides page boundaries with a different comparator.

Search comparison is not sort order

A forgiving match can be useful in a typeahead:

const matcher = new Intl.Collator("ar", {
  usage: "search",
  sensitivity: "base",
});

const sameForThisMatch = matcher.compare("أحمد", "احمد") === 0;

The tested runtime returned true. That does not make the two strings identical, and it does not justify merging two customer records.

The distinction is built into ECMA-402. It selects different locale data for usage: "sort" and usage: "search", and warns that a search collation is only for finding matches because it is not guaranteed to have a particular order. ICU likewise exposes string search as a higher-level operation, separate from comparison and sort-key generation.

Use the search comparator for small in-memory candidate sets. A production search service still needs tokenization, ranking, field weighting and an explicit query contract. Collation alone does not supply relevance.

Put the database order in the schema

PostgreSQL can apply a collation per column or per operation. With an ICU-enabled build, create a named collation so queries and indexes refer to one reviewed object:

CREATE COLLATION ar_display (
  provider = icu,
  locale = 'ar-u-kn',
  deterministic = true
);

CREATE TABLE people (
  id bigint PRIMARY KEY,
  display_name text NOT NULL,
  login_handle text COLLATE "C" NOT NULL UNIQUE
);

CREATE INDEX people_name_ar_idx
  ON people ((display_name COLLATE ar_display), id);

SELECT id, display_name
FROM people
ORDER BY display_name COLLATE ar_display, id;

This DDL and query were executed on PostgreSQL 18.6 with ICU 78.3. The kn Unicode locale key enables numeric ordering. The index uses the same collation expression and the same tie-breaker as the query, which gives the planner an index that matches the requested order once the table is large enough to justify using it.

PostgreSQL's collation documentation distinguishes ICU from operating-system libc collations and notes that provider version and definition affect stability. Avoid relying on whatever locale happened to be installed when a host was built. Name the provider and locale in migrations, then verify them in each environment.

MySQL encodes some of this policy in collation names. For example, MySQL documents utf8mb4_0900_ai_ci as using UCA 9.0.0 weights. It also documents a behavior change that catches migrations: UCA 9.0 collations use NO PAD, while older UCA collations commonly use PAD SPACE, so trailing-space comparisons can change. Record the exact MySQL collation, not just utf8mb4, in schema reviews and migration tests.

Keep equality and uniqueness deliberate

PostgreSQL deterministic collations only treat byte-identical strings as equal after the locale comparison. Nondeterministic ICU collations can treat different byte sequences as equal. The documentation shows how lower comparison strengths can ignore selected differences, but also notes a performance cost and restrictions on some pattern-matching operations.

That behavior can be correct for a particular equality rule. It is dangerous as an accidental default for a unique constraint.

A loose collation may cause a unique index to reject two spellings that the business considers separate. A strict or binary constraint may allow values that the login flow later treats as equivalent. Decide identity first, then encode it. Keep display labels out of authentication identifiers whenever possible, and test the exact pairs that product and support teams care about.

The same warning applies to GROUP BY, joins and deduplication. If collation equality is used there, it can change counts and merge buckets. Display order is rarely a safe deduplication policy.

Make cursor pagination a total order

ORDER BY display_name COLLATE ar_display is not enough for a cursor. Several rows can share a name, and some collations can consider different strings equal at the active comparison level. Add an immutable unique key:

SELECT id, display_name
FROM people
WHERE display_name COLLATE ar_display > $1 COLLATE ar_display
   OR (
     display_name COLLATE ar_display = $1 COLLATE ar_display
     AND id > $2
   )
ORDER BY display_name COLLATE ar_display, id
LIMIT 50;

The cursor must carry both the last display_name and id, plus a version for the cursor format. Sign or authenticate it if clients must not edit it.

A unique tie-breaker prevents ambiguity inside one collation version. It cannot freeze order across an ICU, CLDR, operating-system or database upgrade. UTS #10 says binary sort-key values are not stable between versions. Do not put opaque library sort keys into long-lived cursors. Expire cursors across collation migrations, rebuild affected database indexes as required by the database upgrade procedure, and run before-and-after ordering fixtures.

Keyset pagination also does not create a snapshot by itself. If records are inserted or renamed between requests, their position can move. Use a database snapshot or a product-level cutoff when the workflow requires a frozen export rather than a live directory.

A deployment contract worth keeping

Write the following into the feature specification and migration review:

  1. The locale and collation provider used for display order.
  2. The options that matter, including numeric ordering, sensitivity and punctuation handling.
  3. Separate equality rules for login, deduplication, grouping and search.
  4. The exact collation on every relevant database column, expression index and query.
  5. A unique final sort key for every paginated order.
  6. A fixture containing Arabic names, mixed scripts, digits, punctuation, empty values and duplicate labels.
  7. An upgrade plan that records runtime, ICU or UCA versions and invalidates old cursors when ordering can change.

The practical boundary is simple: collators answer language-order questions. Constraints answer identity questions. Search answers relevance questions. Pagination needs a total database order. Once those contracts are separate, Arabic sorting stops being a UI patch and becomes an ordinary, testable part of the data model.

Cover credit: SultanByte editorial artwork based on the cited Unicode, ECMA-402, ICU, PostgreSQL and MySQL technical sources.