12. Sorting & Custom Comparators

Sorting by key vs. hand-written comparator, chaining multi-field tiebreakers, and dodging JS's default lexicographic-sort trap.

Every language ships a general-purpose comparison sort, but the ergonomics of customizing that sort — by key, by multiple fields, by reversed order — and the guarantees you can rely on (especially stability) differ sharply enough to cause real interview bugs, particularly for anyone assuming JavaScript's sort() behaves like Python's or Java's by default. Go's slices.Sort/SortFunc is clean but has no key= lambda — you write a Less or a 3-way cmp.

Language Verdict: Pros, Cons & Recommendation

Python

4/5
  • key= is called once per element (O(n)) via decorate-sort-undecorate, not once per comparison
  • Tuple keys ((x.a, x.b)) give free lexicographic multi-field sorting with zero extra syntax
  • sorted()/.sort() are unconditionally stable, and reverse=True preserves that stability instead of flipping tie order
  • operator.attrgetter/itemgetter shave off the per-call lambda overhead for simple key extraction
  • Mixing ascending and descending across multiple non-numeric fields needs functools.cmp_to_key, since negating a key only works for numeric fields
  • sorted() returns a new list while .sort() returns None — writing x = arr.sort() is a real, easy mistake

Java

5/5
  • Comparator.comparing(...).thenComparing(...).reversed() chains readably and lets every field's direction be set independently, even non-numeric ones
  • Comparable/compareTo gives a class a real natural-ordering protocol, picked up automatically by Collections.sort and TreeSet
  • Arrays.sort(Object[])/Collections.sort are guaranteed stable (TimSort-derived)
  • No default-sort footgun — natural ordering on int[]/Integer[] is always numeric
  • Arrays.sort(int[]) on primitives uses an unstable dual-pivot Quicksort — a real trap if you're sorting a parallel array by the same indices
  • compareTo implementations risk integer-overflow bugs from manual subtraction; must use Integer.compare(a, b) instead

Go

4/5
  • slices.Sort on cmp.Ordered types is a one-liner with numeric (not lexicographic) ordering
  • slices.SortFunc + cmp.Compare is overflow-safe 3-way compare; cmp.Or (1.22+) cascades tiebreakers
  • slices.SortStable/SortStableFunc make the stable/unstable choice explicit by name
  • sort.Slice with a Less is still widely recognized if the interviewer is on an older Go
  • No key= extractor — every custom sort writes a Less or 3-way cmp, and the key is recomputed per comparison
  • No Comparable protocol for structs; slices.Sort will not pick up a method you define
  • slices.Sort is not stable — you must remember SortStable when tie order matters

JavaScript

2/5
  • Array.prototype.sort has been spec-guaranteed stable since ES2019 in every modern engine
  • The comparator callback is a first-class, unambiguous negative/zero/positive style, no adapter function needed
  • ES2023's toSorted() gives a genuine non-mutating sort without the [...arr] copy idiom
  • No key-extractor sugar at all — every sort recomputes both sides' keys inline, up to O(n log n) extractions vs. Python's O(n)
  • Bare .sort() on numbers sorts lexicographically by default (10 before 2) — one of the most common real interview bugs
  • No Comparable-equivalent protocol — there's no way to give a class a natural ordering picked up automatically by anything
  • .sort() mutates in place AND returns the same reference, easy to misread as a new array
Recommendation: Python's key= is still the typical DSA default for speed-of-writing. Java's Comparator.comparing().thenComparing() is the gold standard for multi-field sorts. Go is a solid nice-to-have (slices.Sort / SortFunc + cmp.Compare) if you remember there is no key= and that Sort is unstable. In JavaScript, never call bare .sort() on numbers — always pass (a, b) => a - b.

Coding Mechanics, Side by Side

Custom Sort by a Key Function vs. a Full Comparator

Must-know

Python and Java let you extract a sort key and let the language derive comparisons from it; Go and JavaScript require you to write the full comparator (Less / 3-way cmp / (a, b) => ...) yourself every time.

from operator import attrgetter items.sort(key=lambda x: x.value) # in-place result = sorted(items, key=attrgetter('value')) # new list # the key function is called once per element (O(n)), not per comparison

key= is the idiomatic Python style — you supply a function from element to sort key, and Python calls it exactly once per element (decorate-sort-undecorate internally), then compares keys with their natural <. attrgetter/itemgetter from operator are marginally faster than an equivalent lambda since they avoid a Python-level function call per invocation.

The JS Default-Sort Trap: sort() Is Lexicographic by Default

Must-know

This is one of the most common real interview bugs for anyone using JavaScript: calling .sort() with no comparator on an array of numbers does NOT sort numerically.

nums = [10, 1, 2] print(sorted(nums)) # [1, 2, 10] — correct, natural numeric ordering by default

Python's default sorted()/.sort() uses each element's natural < ordering — for numbers, that's always numeric comparison. There is no equivalent trap: sorting a list of ints or floats with no key= just works correctly.

Multi-Key Sort: Primary Field, Then Tiebreaker

Must-know
# tuple comparison is lexicographic: compares x.a first, x.b only on ties items.sort(key=lambda x: (x.a, x.b)) # mixed ascending/descending: negate the numeric field you want descending items.sort(key=lambda x: (x.a, -x.b))

Tuples compare element-by-element left to right, stopping at the first difference — exactly the semantics you want for 'sort by A, break ties by B'. Mixing ascending/descending fields is easy for numeric fields (negate the ones you want reversed) but requires functools.cmp_to_key for a non-numeric field you want reversed (e.g. a descending string field).

Reverse / Descending Order

Recommended
sorted(items, reverse=True) # simplest: literal reverse sorted(items, key=lambda x: x.value, reverse=True) # reverse combined with a key sorted(items, key=lambda x: -x.value) # negate instead (numeric only)

reverse=True reverses the FINAL ordering while staying stable — ties keep their original relative order — which is subtly different from negating the key (which flips comparisons but only works for numeric keys). For non-numeric keys where you want a reversed tiebreaker, reverse=True on the whole sort or a cmp_to_key-based comparator are the real options.

Sort Stability: Which Sorts Preserve Tie Order

Must-know

Stability — whether equal elements keep their original relative order — matters whenever you sort by one field but care about a secondary, implicit order (e.g. original insertion order, or a previous sort pass) for elements that tie.

# Timsort — ALWAYS stable, guaranteed by the language, for both sorted() and .sort() data = [(1, 'b'), (1, 'a'), (0, 'z')] data.sort(key=lambda x: x[0]) print(data) # [(0, 'z'), (1, 'b'), (1, 'a')] — the two 1's keep their relative order

Python guarantees stability for sorted()/list.sort() unconditionally — this is documented language behavior, not an implementation detail, and it's part of why 'sort by B first, then stable-sort by A' (two chained single-key sorts) reliably produces a correct multi-key sort in Python.

Old-Style Comparator Functions: cmp_to_key and Native Equivalents

Optional

The classic '(a, b) -> negative/zero/positive' comparator style is native to Java, Go (slices.SortFunc), and JS but is the odd one out for Python, which defaults to key-extraction instead.

from functools import cmp_to_key def compare(a, b): if a.value != b.value: return a.value - b.value return 0 items.sort(key=cmp_to_key(compare))

Python's sorted/.sort only accept key=, never a raw two-argument comparator — functools.cmp_to_key exists specifically to adapt an old-style comparator function into something the key-based API can consume. You'll mostly reach for this when porting a comparator from another language, or when a comparison genuinely can't be expressed as 'extract a key and compare keys' (rare, but it happens with some custom transitive-ordering logic).

In-Place Mutation vs. Returning a New Sorted Collection

Recommended

Every language distinguishes 'sort this collection in place' from 'give me a new sorted collection, leave the original alone' — mixing them up is a real, easy-to-make bug.

arr = [3, 1, 2] new_list = sorted(arr) # returns a NEW list, arr untouched arr.sort() # sorts arr IN PLACE, returns None bug = arr.sort() # bug: bug is None, not the sorted list!

sorted() always returns a new list; .sort() always mutates in place and returns None. The classic mistake is x = arr.sort() expecting x to be the sorted list — it's actually None, since .sort()'s return value is intentionally None to signal 'this was a mutation, not a new value' (the same design philosophy as most in-place Python list methods).

Natural Ordering for Custom Objects: Comparable vs. Rich Comparisons vs. Nothing

Recommended

Sorting a collection of custom objects with no explicit comparator requires each language to answer 'what does less than even mean for this type?' — Python, Java, Go, and JavaScript answer that question very differently.

from functools import total_ordering @total_ordering class Item: def __init__(self, value): self.value = value def __eq__(self, other): return self.value == other.value def __lt__(self, other): return self.value < other.value items.sort() # uses __lt__ directly — no key= needed

Defining __lt__ (and __eq__) makes a class sortable with plain sorted(items)/items.sort() — no key= required, since Python's comparison operators dispatch to these dunder methods directly. functools.total_ordering fills in __le__, __gt__, __ge__ automatically from just __eq__ + __lt__, saving you from writing all four rich-comparison methods by hand.

Further Reading

  • Sorting HOW TOPython official docs

    Official guide covering key functions, `cmp_to_key`, and stability guarantees directly from the source.

  • Specifies `comparing`, `thenComparing`, and `reversed` directly — the full chainable-comparator API used throughout this section.

  • States explicitly that primitive-array sort uses a dual-pivot Quicksort — the primary source for the non-stability claim in this section.

  • Documents the default lexicographic-sort trap, the ES2019 stability requirement, and the newer `toSorted()` alternative.

  • The primary-source spec text that mandates stable sort behavior — useful for verifying the ES2019 stability claim directly rather than trusting secondhand summaries.

  • Effective Java, 3rd Ed. — Item 14 (Comparable)Joshua Bloch, Effective Java (book)

    The definitive treatment of implementing `Comparable` correctly, including the `compareTo` overflow trap this section calls out.

  • Canonical reference for `slices.Sort` / `SortFunc` / `SortStable` and the in-place vs. `slices.Sorted` copy APIs used throughout this section. See also https://pkg.go.dev/cmp for `cmp.Compare` / `cmp.Or`.