Skip to main content

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

Updated 9 min readMarkdown

FindUtils' post Why 10 Sorts Before 2 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:

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

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));
[ '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 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:

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:

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)
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:

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)
['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 package's natsorted() gives the same order and handles more edge cases.

The shell: sort -V and ls -v

$ 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:

$ 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:

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:

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 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:

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;
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:

-- 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:

c := collate.New(language.English, collate.Numeric)
c.SortStrings(files)
fmt.Println(c.CompareString("file02.txt", "file2.txt"))
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 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:

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:
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)
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 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 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, including fast REST APIs and back-end services built in Go.

About the author

  • 463 free browser tools. No sign-up, no install. https://findutils.com

No comments yet. Be the first to comment.