Sorting Text Is Not One Operation: Four Orderings That All Call Themselves Alphabetical
Published 5/29/2025 · 12 min read · Text & language tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 5 sources
"Alphabetical" names at least four different orderings, and they disagree on ordinary input. JavaScript's default .sort() compares UTF-16 code units, so ["Zebra","apple","Banana","zebra"] comes out Banana, Zebra, apple, zebra — every capital before every lowercase letter — and ["item10","item9","item2"] comes out item10, item2, item9, because "1" is a smaller character than "9". Ask instead for an Intl.Collator with numeric: true and the same three strings come out item2, item9, item10. Locale collation is a third ordering: the German list ["Öl","Ohr","Zebra","Ähre"] sorts as Ähre, Ohr, Öl, Zebra under de, and as Ohr, Zebra, Ähre, Öl under sv, because ä and ö sit beside a and o in German and after z in Swedish — and the default .sort() happens to produce the Swedish answer for a German list. A fourth is collation variant: de-u-co-phonebk, the German phone-book ordering, treats ö as oe and moves Öl in front of Ohr. Array.prototype.sort has been required to be stable since ES2019, so equal keys keep their input order. Sorting user-visible text without naming a locale is a bug, not a shortcut.
Code-unit order, natural order and locale collation run on the same list in Node, with the outputs printed. Why Zebra beats apple, why item10 beats item9, and why ä sits next to a in German but after z in Swedish.
Four things called alphabetical
A sort button offers one option and implies there is one answer. There are at least four, and they are not refinements of each other — they are different orderings that return different lists from the same input. Code-unit order compares the numeric values of the UTF-16 units a string is made of. Natural order reads runs of digits as numbers. Locale collation applies a language's own rules about which letters count as the same letter. Collation variants then split a single language into several defensible orders, because German dictionaries and German phone books have never agreed.
Everything below was run rather than remembered. Every list is printed exactly as Node 26.3 returned it, and the scripts are small enough to retype: an array, a sort, a console.log.
Code-unit order: what .sort() actually does
Called with no comparator, Array.prototype.sort converts each element to a string and compares those strings by UTF-16 code unit. That is a documented rule, not an accident, and it produces two visible symptoms. Capital letters occupy the range 0x41–0x5A and lowercase letters 0x61–0x7A, so every capital sorts before every lowercase letter: ["Zebra","apple","Banana","zebra"] returns Banana, Zebra, apple, zebra. And digits are compared as characters, so ["item10","item9","item2"] returns item10, item2, item9 — "1" is 0x31 and "9" is 0x39, and the comparison stops at the first difference.
The third symptom is the one that reaches users. Every letter with a diacritic lives above 0x7A, so code-unit order pushes the whole accented alphabet behind z. ["Öl","Ohr","Zebra","Ähre"] returns Ohr, Zebra, Ähre, Öl. ["ñu","nube","niño","zorro"] returns niño, nube, zorro, ñu. ["ação","acordo","água","avô"] returns acordo, avô, ação, água. ["étage","effet","zèbre","Île"] returns effet, zèbre, Île, étage. Four languages, four wrong answers, one line of code.
Code-unit order is not useless. It is total, transparent, fast — 20 000 words sorted in 6 ms in the benchmark below — and identical in every runtime and every locale, which makes it the right choice for anything a machine reads: index keys, deduplication buckets, cache identifiers, canonical serialisations. It is only wrong when the output is meant for a person.
Natural order: reading digits as numbers
Natural order — the ordering a file manager uses — treats a run of digits inside a string as a single number instead of a sequence of characters. In JavaScript it is one option: new Intl.Collator("en", { numeric: true }). On ["item10","item9","item2"] it returns item2, item9, item10, and on a longer list ["item2","item9","item10","item100","item20"] it returns item2, item9, item10, item20, item100, where the default .sort() returns item10, item100, item2, item20, item9.
Two limits are worth knowing before you switch it on everywhere. Numeric collation is a display convenience, not arithmetic: it compares digit runs, so it has opinions about "v1.10" against "v1.9" that a semantic-version parser would not share, and it says nothing useful about signs, decimal separators or thousands separators. And it changes the answer for keys that merely happen to contain digits, such as product codes where 0090 and 90 are different articles. Turn it on for lists a person scans, leave it off for identifiers.
Locale collation: German against Swedish
Locale collation is the classic case, and German against Swedish is the classic pair. Take ["Öl","Ohr","Zebra","Ähre"]. Under new Intl.Collator("de") it sorts Ähre, Ohr, Öl, Zebra: ä is a variant of a, ö a variant of o, and the diacritic only breaks ties. Under new Intl.Collator("sv") the same four strings sort Ohr, Zebra, Ähre, Öl: in Swedish, å, ä and ö are the last three letters of the alphabet, after z. Neither is a bug. They are two languages with two alphabets, and the list has to pick one.
Now the uncomfortable part: for this list, the default .sort() returns Ohr, Zebra, Ähre, Öl — character for character the Swedish answer. A program that skipped the locale did not produce "no ordering in particular". It produced a specific foreign ordering, silently, and it will keep producing it for every German, Spanish, Portuguese and French list it touches.
The other locales in this article behave the same way. Spanish makes ñ a letter of its own after n: ["ñu","nube","niño","zorro"] sorts niño, nube, ñu, zorro under es, while code-unit order exiles ñu past zorro. Portuguese treats the accents as tie-breakers, so ["ação","acordo","água","avô"] comes out in dictionary order under pt and scrambled under .sort(). Italian sorts ancora, Àncora, elite, zucchero under it, where the accent is a secondary difference and the capital a tertiary one. And French has an ordering that is genuinely regional: on ["cote","coté","côte","côté"], fr returns cote, coté, côte, côté, while fr-CA returns cote, côte, coté, côté, because Canadian French compares accents from the end of the word backwards.
One language, several orders: collation variants
Even inside one language there is more than one correct answer, and Unicode's CLDR ships them as named variants selected through the locale string. German has two in daily use. Standard dictionary collation, de, sorts ["Göbel","Goethe","Godel","Gözde","Gott"] as Göbel, Godel, Goethe, Gott, Gözde: ö is a variant of o. Phone-book collation, de-u-co-phonebk, sorts the same five as Godel, Göbel, Goethe, Gözde, Gott, because ö is expanded to oe — which places Göbel between Godel and Goethe, exactly where a person looking up "Goebel" would search.
Two dials sit beside the variant and change the answer as much as it does. sensitivity decides which differences count at all: on "cote" against "côte" and "cote" against "Cote", sensitivity "base" reports both pairs as equal, "accent" separates the accent but not the case, "case" separates the case but not the accent, and "variant" — the default — separates both. caseFirst decides which side of a tie wins: on ["apple","Apple","APPLE"], the default returns apple, Apple, APPLE, and caseFirst: "upper" returns APPLE, Apple, apple. Neither dial is cosmetic. sensitivity is also what makes a collator a search tool: with "base", compare returns 0 for strings a reader would call the same word.
Stability: what happens to ties
A sort is stable when elements that compare equal keep the relative order they had in the input. This matters the moment you sort on a partial key — a first letter, a category, a date without a time — because the ties are not rare edge cases, they are most of the list. Sorting ten first names by their initial letter alone, the elements with initial a came out in input positions 1, 2, 4, 6, 8 and those with initial b in positions 0, 3, 5, 7, 9: every group preserved, in order.
Array.prototype.sort has been required by the specification to be stable since ES2019. Before that, engines were free to use an unstable algorithm above a certain array length, and several did, which is why older advice tells you to sort with a composite comparator or to carry the index along. The requirement holds regardless of size: a thousand-element array sorted on a key with only three distinct values came back with every group in input order. You can therefore build a multi-key sort by sorting several times, least significant key first — sort by first name, then by surname — and rely on the earlier passes surviving.
What it costs, and the rule that follows
Locale-aware sorting is more expensive than code-unit sorting, but not by the amount people fear, and the expensive mistake is a different one. Sorting 20 000 words in Node 26.3: the default .sort() took 6 ms, a.localeCompare(b, "de") took 12 ms, a collator built once and reused took 28 ms — and constructing a new Intl.Collator inside the comparator took 1 771 ms, sixty times slower than reusing one. The cost is not collation. The cost is building the collator hundreds of thousands of times.
Which leaves a rule short enough to apply. If a machine reads the output, sort by code unit and write down that you did. If a person reads it, name a locale — the language the document is written in, not the language of the browser that happens to render it — build one Intl.Collator, decide numeric and sensitivity on purpose, and reuse it. The one thing that is never defensible is calling .sort() on user-visible text and calling the result alphabetical.
| Ordering | How you ask for it | German list | Numbered list |
|---|---|---|---|
| Code-unit order | arr.sort() | Ohr, Zebra, Ähre, Öl | item10, item2, item9 |
| German collation | new Intl.Collator("de") | Ähre, Ohr, Öl, Zebra | item10, item2, item9 |
| German phone-book variant | new Intl.Collator("de-u-co-phonebk") | Ähre, Öl, Ohr, Zebra | item10, item2, item9 |
| Swedish collation | new Intl.Collator("sv") | Ohr, Zebra, Ähre, Öl | item10, item2, item9 |
| Numeric-aware collation | new Intl.Collator("de", { numeric: true }) | Ähre, Ohr, Öl, Zebra | item2, item9, item10 |
Frequently asked questions
- Why does "Zebra" sort before "apple"?
- Because .sort() with no comparator compares UTF-16 code units, and every ASCII capital letter (0x41–0x5A) has a smaller value than every ASCII lowercase letter (0x61–0x7A). It is not sorting letters, it is sorting numbers that happen to represent letters. Any locale collator fixes it: new Intl.Collator("en").compare puts apple, Banana, zebra, Zebra in that order, treating case as the last tie-breaker rather than the first criterion.
- Is JavaScript's sort stable?
- Yes, and it is required to be. ES2019 made stability a specification requirement for Array.prototype.sort and Array.prototype.toSorted follows the same rule. Verified here on a thousand-element array sorted by a key with three distinct values: every group came back in input order. That guarantee is what lets you implement a multi-column sort as a sequence of single-column sorts, running the least significant column first.
- Which locale should I use if I do not know the reader's?
- Use the language of the content, not the language of the reader's device. A list of German product names belongs in German collation whoever is looking at it, exactly as a printed German catalogue would be. Falling back on the runtime default is the worst option, because it is invisible: new Intl.Collator() with no argument resolved to en-US on the machine this article was written on, and would resolve to something else on the next one, so the same list would order differently on two servers with no code change.
- Why does my file manager order item2 before item10 but my code does not?
- The file manager uses natural order: it recognises the digit run as a number. Your code compares characters, so it stops at "1" against "9" and never reads the rest. Add { numeric: true } to an Intl.Collator, or pass the same option through localeCompare, and the two agree. Do not try to emulate it by zero-padding the display strings — that fixes the sort and breaks the labels.
- Is localeCompare too slow for a long list?
- Not on its own. On 20 000 words in Node 26.3, a.localeCompare(b, "de") took 12 ms and a reused Intl.Collator took 28 ms against 6 ms for the default .sort() — a difference nobody will notice. What is genuinely slow is constructing a collator inside the comparator: the same sort took 1 771 ms that way, because a fresh collator is built for every one of the hundreds of thousands of comparisons. Build it once, outside the sort, and pass its .compare.
- How do I sort German names the way a phone book does?
- Ask for the collation variant by name: new Intl.Collator("de-u-co-phonebk"). The -u-co- part of a locale identifier selects a collation, and phonebk is the German phone-book tailoring, in which ö behaves as oe, ä as ae and ü as ue. On ["Göbel","Goethe","Godel","Gözde","Gott"] plain de gives Göbel, Godel, Goethe, Gott, Gözde and de-u-co-phonebk gives Godel, Göbel, Goethe, Gözde, Gott. Other languages have their own variants — a Chinese list can be ordered by pinyin or by stroke count the same way.
Articles you may find interesting
All guides →Related tools
Sources
- Unicode Consortium — Unicode Technical Standard #10: Unicode Collation Algorithm
- Unicode Consortium — CLDR — Common Locale Data Repository, collation charts and locale tailorings
- Ecma International — ECMAScript Language Specification — Array.prototype.sort (stability) and the Intl.Collator constructor
- Ecma International — ECMAScript Internationalization API Specification (ECMA-402) — Intl.Collator options: usage, sensitivity, numeric, caseFirst
- MDN Web Docs — Intl.Collator and String.prototype.localeCompare
Spotted a mistake in this article?