Why Array.sort() Gives Wrong Results Without a Compare Function
You have an array of numbers, you call .sort(), and the result is wrong. Numbers like 10 and 100 appear before 9, and you have no idea why. This is one of the most common JavaScript gotchas, and it catches experienced developers off guard too.
The short answer: Array.sort() converts every element to a string and sorts by Unicode code point order unless you tell it otherwise. Once you understand that rule, the behavior makes perfect sense β and the fix is straightforward.
What You'll Learn
- Why
Array.sort()defaults to string comparison and what that means in practice - How to write a compare function that sorts numbers correctly
- How to sort arrays of objects by a property
- How to handle edge cases like
null,undefined, and mixed types - Common pitfalls that will still trip you up even after you add a compare function
The Default Behavior: String Sorting
The ECMAScript specification says that when no compare function is provided, Array.sort() converts each element to a string and orders them by their UTF-16 code unit values. That sounds abstract, but the practical effect is this:
const numbers = [10, 9, 2, 100, 21];
numbers.sort();
console.log(numbers); // [10, 100, 2, 21, 9]The result feels random, but it is perfectly consistent. As strings, "10" comes before "2" because the character '1' has a lower code point than '2'. It is dictionary order, not numeric order.
This behavior is intentional. JavaScript's sort() was designed to work on strings first β think sorting a list of names β and numeric sorting requires you to opt in explicitly.
The Fix: A Compare Function
The compare function takes two arguments, conventionally named a and b, and must return a number that tells the sort algorithm how to order them:
- Return a negative number to place
abeforeb - Return zero to leave their relative order unchanged
- Return a positive number to place
bbeforea
For numbers, the shortest correct form is subtraction:
const numbers = [10, 9, 2, 100, 21];
numbers.sort((a, b) => a - b);
console.log(numbers); // [2, 9, 10, 21, 100]When a is smaller than b, a - b is negative, so a comes first. Descending order flips the subtraction:
numbers.sort((a, b) => b - a);
console.log(numbers); // [100, 21, 10, 9, 2]Why Subtraction Works (And When It Doesn't)
The subtraction trick is concise and fast, but it breaks when your numbers include Infinity, -Infinity, or NaN. Subtracting two Infinity values produces NaN, which the sort engine treats as zero β meaning elements end up in an arbitrary order.
If your data could contain those values, use an explicit comparison instead:
const safeNumericSort = (a, b) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
};
const values = [Infinity, 3, -Infinity, 1, NaN, 2];
values.sort(safeNumericSort);
console.log(values); // [-Infinity, 1, 2, 3, Infinity, NaN]Note that NaN never satisfies < or >, so it will settle at the end of the array.
Sorting Strings Correctly
The default sort actually does a reasonable job with plain ASCII strings, but it fails on accented characters, mixed-case names, and locale-sensitive text. The right tool for string sorting is localeCompare:
const names = ['Zara', 'alice', 'BjΓΆrn', 'bob'];
names.sort((a, b) => a.localeCompare(b));
console.log(names); // ['alice', 'BjΓΆrn', 'bob', 'Zara']localeCompare respects the user's locale and handles diacritics properly. You can also pass options like { sensitivity: 'base' } for case-insensitive sorting.
names.sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
);Sorting Arrays of Objects
Real-world data rarely lives in flat arrays. More often you are sorting objects by a property β a list of users by last name, or a list of orders by date.
const products = [
{ name: 'Widget', price: 29.99 },
{ name: 'Gadget', price: 9.99 },
{ name: 'Doohickey', price: 149.00 },
];
// Sort by price ascending
products.sort((a, b) => a.price - b.price);
console.log(products.map(p => p.name));
// ['Gadget', 'Widget', 'Doohickey']Sorting by a string property uses the same localeCompare approach:
products.sort((a, b) => a.name.localeCompare(b.name));
console.log(products.map(p => p.name));
// ['Doohickey', 'Gadget', 'Widget']For multi-level sorting β say, sort by category first, then price within each category β chain comparisons:
const items = [
{ category: 'B', price: 10 },
{ category: 'A', price: 30 },
{ category: 'A', price: 10 },
{ category: 'B', price: 5 },
];
items.sort((a, b) => {
const catOrder = a.category.localeCompare(b.category);
if (catOrder !== 0) return catOrder;
return a.price - b.price;
});
// Result: A/10, A/30, B/5, B/10Sort Is Mutating β Don't Forget That
Array.sort() sorts the array in place and returns a reference to the same array. It does not create a copy. If you need the original order preserved, spread the array first:
const original = [3, 1, 4, 1, 5];
const sorted = [...original].sort((a, b) => a - b);
console.log(original); // [3, 1, 4, 1, 5] β unchanged
console.log(sorted); // [1, 1, 3, 4, 5]This trips up developers who expect sort() to behave like map() or filter(), both of which return new arrays.
Is JavaScript's Sort Stable?
A sort is stable if elements that compare as equal retain their original relative order. Modern JavaScript engines (V8, SpiderMonkey, JavaScriptCore) all implement stable sort, and the ECMAScript specification has required stability since ES2019. If you are running in a very old environment, you cannot rely on it β but for any reasonably modern browser or Node.js version, stability is guaranteed.
This matters when you do multi-pass sorting: sort by price first, then sort by category. A stable sort preserves the price ordering within each category group.
Common Pitfalls
Comparing dates as strings
ISO 8601 date strings ("2024-01-15") happen to sort correctly as strings because of their format. But if your date strings are in a locale-specific format like "15/01/2024", string sort gives garbage results. Convert to timestamps first:
const events = [
{ title: 'Launch', date: '15/03/2024' },
{ title: 'Review', date: '02/01/2024' },
];
events.sort((a, b) => {
// Parse DD/MM/YYYY to a comparable value
const toMs = str => {
const [d, m, y] = str.split('/');
return new Date(`${y}-${m}-${d}`).getTime();
};
return toMs(a.date) - toMs(b.date);
});Forgetting to return a value from the compare function
If your compare function returns undefined (a missing return statement), the engine receives NaN and sort behavior is implementation-defined. Always make sure every code path returns a number.
Accidentally sorting numbers stored as strings
If your array contains "10", "9", "2" as strings, numeric subtraction silently coerces them and works β until the array contains a non-numeric string, at which point NaN creeps back in. Parse explicitly if you are unsure of your data types:
const mixed = ['10', '9', '2'];
mixed.sort((a, b) => Number(a) - Number(b));
console.log(mixed); // ['2', '9', '10']Modifying the array inside the compare function
Mutating the array you are sorting from within the compare function produces undefined behavior. The sort algorithm assumes the input is stable while it runs. Keep your compare functions pure.
Wrapping Up
The default sort behavior is not a bug β it is a documented design decision that optimizes for string sorting. Once you know the rule, you will never be surprised by it again.
Here are concrete actions to take right now:
- Audit your existing sort calls. Search your codebase for
.sort()without a compare function and verify each one is actually sorting strings where dictionary order is acceptable. - Adopt a numeric sort utility. Add a small helper like
const byNum = (key) => (a, b) => a[key] - b[key];to your project's utils file so you stop writing the same lambda repeatedly. - Use
localeComparefor user-facing strings. Anywhere a user sees sorted text β names, labels, categories β switch from default sort to alocaleCompare-based compare function. - Spread before sorting any array you need to keep immutable. One
[...]spread prevents a whole class of subtle bugs when that array is read elsewhere. - Write a test for your sort logic. A two-line unit test with a known input and expected output catches regressions instantly and documents the intended behavior for future maintainers.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!