# Natural Sort in Code: Why file10 Lands Before file2, and the Fix in JavaScript, Python, SQL, Go and the Shell

FindUtils' post [Why 10 Sorts Before 2](https://findutils.com/blog/why-10-sorts-before-2-how-text-line-sorting-works/) explains the rule behind the problem: text sorting compares one character at a time, so `1` beats `2` and `file10.txt` comes before `file2.txt`. It also lists the three fixes: sort by number, use a natural sort, or pad the numbers with zeros.

This is the code-side companion. The same four file names go through JavaScript, Python, the shell, Postgres, SQLite, DuckDB and Go, followed by the cases where natural sort gives the wrong answer.

The list:

```text
file10.txt  file2.txt  File3.txt  file1.txt
```

Everything below was run with Node 20.19, Python 3.13, GNU coreutils 9.7, PostgreSQL 17.11, SQLite 3.46, DuckDB 1.5.6 and Go 1.27 with `golang.org/x/text` v0.43. Outputs are copied from those runs.

## JavaScript: reuse one `Intl.Collator`

```js
const files = ["file10.txt", "file2.txt", "File3.txt", "file1.txt"];

console.log([...files].sort());
const natural = new Intl.Collator("en", { numeric: true });
console.log([...files].sort(natural.compare));
```

```text
[ 'File3.txt', 'file1.txt', 'file10.txt', 'file2.txt' ]
[ 'file1.txt', 'file2.txt', 'File3.txt', 'file10.txt' ]
```

The default `sort()` compares UTF-16 code units, so the capital `F` goes first and `10` beats `2`. The [`Intl.Collator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator) with `numeric: true` reads runs of digits as numbers and uses locale-aware letter order, so `File3.txt` sits between 2 and 10. Add `sensitivity: "base"` if `File3` and `file3` should count as equal.

`a.localeCompare(b, "en", { numeric: true })` gives the same order, but it can be much slower. Sorting 100,000 file names:

```text
localeCompare with options: 8056 ms
one Intl.Collator:          374 ms
```

Three runs gave 7.9 to 8.1 seconds against 0.29 to 0.37 seconds, more than 20 times slower. Build the collator once and pass `natural.compare`.

## Python: `re.split`, not `re.findall`

Python has no built-in natural sort, so the usual answer is a key function. The first version most people write breaks:

```python
import re

def key_findall(s):
    return [int(t) if t.isdigit() else t for t in re.findall(r"\d+|\D+", s)]

sorted(["10 apples", "apple"], key=key_findall)
```

```text
TypeError: '<' not supported between instances of 'str' and 'int'
```

`"10 apples"` starts with a number and `"apple"` with text, so Python ends up comparing `10` with `"apple"`. It passes on file names and fails on the first line that starts with a digit.

`re.split` with a capture group always returns text, number, text, numberâ€¦, starting with text (an empty string if the line starts with a digit). Same-type values always meet:

```python
def natural_key(s):
    return [int(t) if i % 2 else t.casefold() for i, t in enumerate(re.split(r"(\d+)", s))]

sorted(files, key=natural_key)
sorted(["10 apples", "apple", "9 pears"], key=natural_key)
```

```text
['file1.txt', 'file2.txt', 'File3.txt', 'file10.txt']
['9 pears', '10 apples', 'apple']
```

`casefold()` makes it case-insensitive. If you'd rather not maintain this, the [natsort](https://pypi.org/project/natsort/) package's `natsorted()` gives the same order and handles more edge cases.

## The shell: `sort -V` and `ls -v`

```text
$ printf '%s\n' file10.txt file2.txt File3.txt file1.txt | LC_ALL=C sort
File3.txt
file1.txt
file10.txt
file2.txt

$ printf '%s\n' file10.txt file2.txt File3.txt file1.txt | sort -V
File3.txt
file1.txt
file2.txt
file10.txt

$ printf '%s\n' file10.txt file2.txt File3.txt file1.txt | sort -n
File3.txt
file1.txt
file10.txt
file2.txt
```

`sort -V` (version sort) fixes the numbers but stays case-sensitive, so `File3.txt` still goes first. `sort -n` doesn't help at all: it only reads a number at the start of a line. Here every line starts with a letter, so all of them tie and fall back to plain text order. For directory listings, `ls -v` ("natural sort of (version) numbers within text", says its help text) gave the same order here.

For dotted numbers without letters, field sorting is the most explicit option:

```text
$ printf '%s\n' 1.10.0 1.9.2 2.0.0 1.10.1 | sort -t. -k1,1n -k2,2n -k3,3n
1.9.2
1.10.0
1.10.1
2.0.0
```

## Postgres: an ICU collation

Postgres can do natural sort in the database with an ICU collation and the `kn` (numeric) option:

```sql
CREATE COLLATION natural_sort (provider = icu, locale = 'und-u-kn-true');

SELECT string_agg(name, ', ' ORDER BY name COLLATE "C") AS bytewise FROM files;
SELECT string_agg(name, ', ' ORDER BY name COLLATE natural_sort) AS natural FROM files;
```

With a fifth row, `file02.txt`, added:

```text
bytewise: File3.txt, file02.txt, file1.txt, file10.txt, file2.txt
natural:  file1.txt, file02.txt, file2.txt, File3.txt, file10.txt
```

(`natural` is a reserved word in both Postgres and SQLite, hence the name `natural_sort`.) You can also index with it, `CREATE INDEX ON files (name COLLATE natural_sort)`, so the database can use that index for an `ORDER BY` with the same collation. The [collation chapter](https://www.postgresql.org/docs/current/collation.html) of the Postgres docs covers the ICU options.

Note where `file02.txt` landed: right next to `file2.txt`. To ICU's numeric comparison, `02` and `2` are the same number. A deterministic collation (the default) still breaks the tie by bytes. A **non-deterministic** one doesn't:

```sql
CREATE COLLATION natural_ci (provider = icu, locale = 'und-u-kn-true-ks-level2', deterministic = false);
SELECT count(DISTINCT name COLLATE natural_ci) FROM files;     -- 4, not 5
SELECT name FROM files WHERE name = 'FILE2.TXT' COLLATE natural_ci;
```

```text
distinct_ci: 4
name: file2.txt
name: file02.txt
```

`FILE2.TXT` matches two different rows, and `DISTINCT` merges them. If you use a collation like this on a column with a `UNIQUE` constraint, `file02.txt` and `file2.txt` can't both exist. That may be what you want for user-facing names and a surprise for anything else. Use non-deterministic collations for comparisons only when you mean it, and keep keys bytewise.

## SQLite and DuckDB: no natural collation built in

Neither has one, so there are two options. If every value has the same shape, sort on the number:

```sql
-- SQLite: CAST reads the leading digits of '10.txt' as 10
SELECT name FROM files ORDER BY CAST(substr(name, 5) AS INTEGER);

-- DuckDB: text part, then the first number, then the name as a tie-breaker
SELECT name FROM files
ORDER BY lower(regexp_extract(name, '^\D*')),
         TRY_CAST(regexp_extract(name, '\d+') AS BIGINT),
         name;
```

Both return `file1.txt, file2.txt, File3.txt, file10.txt`. For any shape in SQLite, register a collation from your host language. In Python, `db.create_collation("natural_sort", fn)` takes a function that returns âˆ’1, 0 or 1 (a wrapper around `natural_key` works) and makes `ORDER BY name COLLATE natural_sort` work. The catch: it exists only on that connection. Another tool opening the same file doesn't have it.

## Go: `golang.org/x/text/collate`

The standard library's `slices.Sort` is bytewise. The `x/text` module has a numeric option:

```go
c := collate.New(language.English, collate.Numeric)
c.SortStrings(files)
fmt.Println(c.CompareString("file02.txt", "file2.txt"))
```

```text
slices.Sort [File3.txt file1.txt file10.txt file2.txt]
collate     [file1.txt file2.txt File3.txt file10.txt]
zeros       0
```

Same order as ICU and the same leading-zero equality. `CompareString` returns 0, so `file02.txt` and `file2.txt` are a tie. [collate on pkg.go.dev](https://pkg.go.dev/golang.org/x/text/collate) lists the other options.

## Where natural sort gets it wrong

Natural sort treats each run of digits as a whole number. That's right for file names and wrong for several other things. From the JavaScript collator:

```text
zeros      0 0                                      // file02.txt vs file2.txt: equal
decimals   [ '$1.5', '$1.25', '$1.125' ]            // 5 < 25 < 125
negatives  [ '-5', '-10', '3' ]                     // the minus sign is just a character
semver     [ 'v1.9.2', 'v1.10.0', 'v2.0.0', 'v2.0.0-rc.1' ]
```

- **Leading zeros** compare as equal. Your output order then depends on the input order, and anything that dedupes on "compare returns 0" will merge them. Python's `natural_key` gives the same: `natural_key("file02.txt") == natural_key("file2.txt")` is `True`.
- **Decimals** are split at the dot, so `1.5` is read as 1, then 5, and sorts below `1.25`. Sort prices and measurements as numbers, not as text.
- **Negative numbers** lose their sign, because `-` is just a character.
- **Release candidates** come after the release, because `v2.0.0` is a prefix of `v2.0.0-rc.1` and shorter sorts first. `sort -V` and `natsort` do the same. Semantic versioning says the opposite: a pre-release comes before its release. Use a version parser:

```python
from natsort import natsorted
from packaging.version import Version

tags = ["v2.0.0", "v2.0.0-rc.1", "v1.10.0", "v1.9.2"]
natsorted(tags)
sorted(tags, key=Version)
```

```text
natsort   ['v1.9.2', 'v1.10.0', 'v2.0.0', 'v2.0.0-rc.1']
packaging ['v1.9.2', 'v1.10.0', 'v2.0.0-rc.1', 'v2.0.0']
```

`packaging` follows Python's version rules (PEP 440), which accept this tag. In JavaScript, the `semver` package's `compare` does the same job for npm-style versions.

## When you don't need code

For a one-off list in a browser tab, the FindUtils [Text Line Sorter](https://findutils.com/text/text-line-sorter/) has no natural sort mode, but its Numeric mode reads the first number in each line, which covers `Item 2` / `Item 10` style lists. The sorting post notes its limits: only the first number counts and it is read as a decimal, so `1,500` reads as 1 and `v1.10` as 1.1.

If you name the files yourself, zero padding beats all of the above, because every program then sorts them the same way: `file001`, `file002`, `file010`. The [Line Prefix & Suffix](https://findutils.com/text/line-prefix-suffix/) tool can number a list with zero padding.

## Cheat sheet

| Where | Natural sort | Watch out for |
|---|---|---|
| JavaScript | `new Intl.Collator("en", { numeric: true }).compare` | Build it once. `localeCompare` with options was over 20Ã— slower here |
| Python | `key=natural_key` with `re.split(r"(\d+)", s)`, or `natsort` | `re.findall` keys raise `TypeError` on lines starting with a digit |
| Shell | `sort -V`, `ls -v` | Case-sensitive. `sort -n` only reads a leading number |
| Postgres | ICU collation `und-u-kn-true` | Non-deterministic variants merge `file02` and `file2` |
| SQLite / DuckDB | `CAST`/`regexp_extract` on a known shape, or a host-language collation | A SQLite collation exists only on the connection that registered it |
| Go | `collate.New(lang, collate.Numeric)` | Leading zeros compare equal |
| Versions | `packaging.Version`, npm `semver` | Natural sort puts `-rc.1` after the release |
| Prices, negatives | Sort as numbers | Natural sort splits at `.` and ignores `-` |

Pick the one that matches what the strings mean. File names want natural sort, versions want a version parser, and amounts want to be numbers before anything sorts them.

---

*Need a backend that gets details like this right? The team behind FindUtils takes on freelance work on [Upwork](https://www.upwork.com/freelancers/~010a5875eab37b4bd7), including [fast REST APIs and back-end services built in Go](https://www.upwork.com/services/product/development-it-a-fast-rest-api-or-back-end-service-built-in-go-2112349609254214411).*
