skip to content

How many times does sorted() call the key= function, and why does it matter?

level: middleimportance: should knowfreq 48%

answer

  1. The cost depends on how often it runs
  2. Not once per comparison
  3. One call for each element, n total
  4. Keys computed up front into an array
  5. Decorate, sort, undecorate is built in

basics

~20 s

Exactly once per element: n calls for n elements. CPython computes every key up front into an internal array and then compares only those key values, so an expensive key costs O(n), not O(n log n).

solid answer

~40 s

`sorted()` evaluates the key callable **once for each element**, before any comparison happens. It stores the results in an internal keys array, sorts that array while moving the values in lockstep, then discards the keys — the decorate-sort-undecorate idiom, built into the interpreter. So the cost is n key calls plus roughly n log n comparisons **of key objects**. If the same transform ran inside a two-argument comparator it would execute on every comparison instead — millions of times for a large batch rather than n times. That is why putting expensive normalization in the key is correct rather than wasteful, why memoizing the key buys nothing for a single sort, and why hand-rolling the decoration is redundant. `min()`, `max()`, `heapq.nsmallest()` and `heapq.nlargest()` behave the same way.

code

python · 9 lines
python
calls = 0

def sort_key(value):
    global calls
    calls += 1
    return value % 10

sorted(range(1000), key=sort_key)
print(calls)

go deeper

for a junior

Know the number: the key function runs once for each element, so a 1000-item list means 1000 calls. You never write the caching yourself, and the keys disappear once the call returns.

for a middle

Explain decorate-sort-undecorate: all keys are computed first into an internal array, then only key objects are compared. State the cost as n key calls plus about n log n comparisons.

for a senior

Quantify it for a real batch — n key evaluations versus roughly n log n if the work happened per comparison — and name the residual risks: expensive comparisons on fat key objects, and n live key objects of memory pressure.

for a principal

Own the choice between computing an ordering key at write time, stored and indexed alongside the data, and computing it at sort time in a lambda. When the same ordering drives exports, pagination and UI, the key belongs in the data model.

### The answer: exactly once per element For `sorted(iterable, key=f)`, `f` is called **n times for n elements** — once each — and never again. It is not called on every comparison. That single fact is what makes `key=` safe to use with work that would be unthinkable inside a comparator. ```python calls = 0 def sort_key(value): global calls calls += 1 return value % 10 sorted(range(1000), key=sort_key) print(calls) # 1000 ``` ### Why: decorate-sort-undecorate is built in CPython implements this with the pattern that used to be written by hand (the "decorate, sort, undecorate" idiom). The implementation: 1. walks the input once, calling the key callable on each element and storing the results in an internal array of keys alongside the array of values; 2. sorts the **keys** array, applying every move to the values array in lockstep; 3. throws the keys array away and returns the reordered values. Two consequences follow. First, all keys are computed *before* any comparison happens, so an exception raised by the key surfaces before the ordering work starts. Second, the comparisons performed during the sort are comparisons **between key objects**, via their `__lt__` — the original elements are never compared at all once keys exist. ### The cost model Say a geocoding batch produces 200,000 candidate address records, and the sort key normalizes each address into a canonical string — strip punctuation, casefold, collapse whitespace. With `key=`: * **200,000** key evaluations, i.e. O(n); * roughly **n log n** comparisons, but of the cheap normalized strings. If the same normalization ran inside a two-argument comparator, it would run on *both operands of every comparison* — on the order of 3.5 million times for the same batch, seventeen times the work, for an identical result. That ratio is the whole point of the parameter, and it is why "just put the expensive transform in the key" is correct advice rather than a hack. `min()` and `max()` are the same story in one pass: n key calls and n-1 comparisons, no sort. `heapq.nsmallest()` and `heapq.nlargest()` also evaluate their key once per element. ### What this means in practice **Do not hand-roll caching.** Wrapping the key in a memoizing decorator for a single `sorted()` call buys nothing — each element is keyed exactly once, so every lookup is a miss plus the cost of hashing the argument. Caching only pays when the *same* elements are sorted repeatedly across calls, or when many distinct elements share a key value and the key is genuinely expensive. **Do not hand-roll the decoration either.** Building `[(f(x), x) for x in data]`, sorting that, and stripping the pairs reproduces exactly what `sorted()` already does, adds n tuple allocations, and introduces a bug the builtin does not have: if two keys tie, the tuple comparison falls through to comparing the *elements*, which may raise `TypeError` for objects with no ordering. Write the decoration yourself only when you actually need the key values afterwards. **Do watch memory.** The keys array holds n references, and the key objects themselves are live for the duration of the sort. A key that returns a large normalized string or a long tuple for every one of several million rows is a real allocation spike on top of the input. **Do watch comparison cost, not just key cost.** The n key calls are linear; the comparisons are not. A cheap key that returns a 40-element tuple, or a very long string that shares a long common prefix with its neighbours, moves the expense into the O(n log n) half where it hurts far more. The sharpest version of this answer names both halves: keys are computed n times, compared about n log n times, so make the key *cheap to compute and cheap to compare*. **Do keep the key pure.** Because keys are all computed up front, a key whose result depends on mutable state that the sort itself changes cannot possibly do what its author intended — the ordering is decided from a snapshot taken before any element moves.

  • Is it worth wrapping an expensive key callable in a memoizing cache?
    Not for a single `sorted()` call: every element is keyed exactly once, so every lookup is a guaranteed miss plus the cost of hashing the argument. Caching pays only when the same elements are ordered repeatedly across calls, or when many distinct elements share one very expensive key value.
  • If keys are cheap to compute, can a sort still be dominated by the key?
    Yes — by comparing it. Key computation is O(n), but comparisons are about n log n, so a key that returns a 40-element tuple or a long string with a long shared prefix moves the expense into the larger half. A good key is cheap to compute *and* cheap to compare.
  • What does it cost in memory to pass a key= to sorted() over a very large list?
    An internal array of n references plus the key objects themselves, all live for the duration of the sort. Returning a large normalized string or long tuple per row for several million rows is a real allocation spike on top of the input list.
  • Why not build a list of (key, item) pairs yourself, sort that, then strip the keys?
    It duplicates what `sorted()` already does, adds n tuple allocations, and adds a bug: when two keys tie, tuple comparison falls through to comparing the items, which raises `TypeError` for objects with no ordering. Do it only when you genuinely need the key values afterwards.

It is like stamping each parcel once with a routing code and then sorting the parcels by the printed stamp, instead of re-reading the full address on both parcels every time you compare two of them.

saying these in an interview costs you the question

  • Says the key runs on every comparison, n log n times
  • Adds manual caching inside the key to avoid repeated calls
  • Claims an expensive key changes the sort's comparison count
  • Thinks keys are recomputed when two elements tie
  • Reimplements decorate-sort-undecorate by hand as an optimization

context