What does sorted()'s key argument receive, and how many times is it called?
answer
- Per element, not per comparison
- Count the calls for n elements
- Tuples compare left to right
- The C-level replacements for those lambdas
- What happens when a key returns None
basics
~20 skey is a one-argument callable applied to each element exactly once, before any comparing happens; the sort then orders elements by those computed key values. For n elements it runs n times, not once per comparison.
solid answer
~40 s`key` is a callable taking **one element** and returning the value to order by. Python computes the key for every element first — exactly `n` calls for `n` elements — and then sorts by those precomputed values, so the original objects are never compared directly. That makes an expensive key affordable in a way a pairwise comparator is not, since a comparator would run O(n log n) times. Returning a **tuple** gives lexicographic multi-field ordering: `key=lambda r: (r.dept, -r.salary)` orders by department ascending, then salary descending. `operator.itemgetter` and `operator.attrgetter` are C-level replacements for the common lambdas and are measurably faster. All keys must be mutually comparable — mixing `str` and `int` keys raises `TypeError` on the first comparison.
code
pycon · 11 lines>>> calls = 0
>>> def digits(n):
... global calls
... calls += 1
... return n % 10
...
>>> data = list(range(1000))
>>> len(sorted(data, key=digits))
1000
>>> calls
1000go deeper
Know that key takes a single element and returns what to sort by, and that key and reverse must be passed by keyword. Recognise key=len and key=str.lower as everyday idioms.
Explain the n-calls-not-n-log-n property and why that made key functions replace comparators, plus tuple keys for multi-field ordering and negation for a descending numeric field.
Demonstrate judgment about total keys: no mixed types, no None holes, missing values normalized with a leading boolean, and expensive key work hoisted out or precomputed rather than recomputed per sort.
Own the readability-versus-cost tradeoff at scale: when to standardize on itemgetter-style keys, when a precomputed sort column belongs on the record itself, and how ordering rules should be expressed once rather than reinvented per call site.
## The contract `key` is any callable of **one argument**. Python calls it on each element and remembers the result; the sort then compares those results, never the elements themselves. When `key` is `None` (the default) the elements are compared directly. This applies identically to `sorted()`, `list.sort()`, `min()`, `max()`, `heapq.nlargest()` and `heapq.nsmallest()` — `key=` is a language-wide convention, not a quirk of one function. ## Exactly n calls The single most-tested fact here: the key function runs **once per element**, before any comparison. CPython builds a parallel array of computed keys and sorts by it, then reassembles the elements — the classic "decorate, sort, undecorate" pattern, done for you in C. So on a list of 100,000 records, a key that parses a timestamp runs 100,000 times, not the ~1.7 million times a pairwise comparator would. This is the whole reason Python 3 dropped comparator functions in favour of key functions: it converts a per-comparison cost into a per-element cost. A consequence people miss: the key is computed even for elements that end up needing few comparisons, and the computed keys are held in memory for the duration of the sort. A key that returns a large object multiplies memory; a key that returns a small tuple of primitives is cheap. ## Tuple keys and multi-field ordering Tuples compare lexicographically: the first components decide, and later components are only consulted on a tie. That gives multi-field ordering for free. ```python rows.sort(key=lambda r: (r["dept"], r["name"])) ``` Mixing directions is the interesting case, because `reverse=` applies to the whole ordering, not to one field. Two idioms cover it. For numbers, negate the field you want descending: `key=lambda r: (r["dept"], -r["score"])`. For values you cannot negate — strings, dates — sort twice, **least significant field first**, and let stability preserve the earlier work: sort by name ascending, then sort by score descending; equal scores keep the name order established by the first pass. One more subtlety about `reverse=True`: it flips the overall order but the sort **remains stable**. Elements with equal keys keep their original relative order rather than appearing reversed. That is a deliberate guarantee, and it is what makes the two-pass idiom above work. ## operator.itemgetter and operator.attrgetter `operator.itemgetter(1)` returns a callable equivalent to `lambda r: r[1]`, and `operator.attrgetter("created")` to `lambda o: o.created`. Both are implemented in C, so they avoid a Python-level function call per element — worth a real percentage on large sorts, and arguably more readable. Both accept several arguments and then return a tuple, which composes perfectly with tuple ordering: ```python from operator import itemgetter, attrgetter rows.sort(key=itemgetter("dept", "name")) objs.sort(key=attrgetter("dept", "name")) ``` `attrgetter` also understands dotted paths (`attrgetter("owner.name")`), and `operator.methodcaller("strip")` covers the "call a method on each element" case. ## Keys must be mutually comparable Python 3 removed default ordering between unrelated types, so a key that returns `int` for some rows and `str` for others raises `TypeError: '<' not supported between instances of 'str' and 'int'` — and only *sometimes*, because the failure depends on whether two mismatched keys are ever compared. A key returning `None` for missing data is the usual culprit. The fix is a **total** key that normalizes the gap, for example `key=lambda r: (r.get("score") is None, r.get("score", 0))`, which sorts the missing values to one end using a boolean as the leading component. ## Common key functions `key=len`, `key=abs`, `key=str.lower` (or `str.casefold` for case-insensitive text beyond ASCII), `key=os.path.getmtime`, and unbound methods passed as functions are all idiomatic — note `str.lower`, not `str.lower()`, since the callable itself is wanted, not the result of calling it. Passing a called expression is the second most common mistake after forgetting that `key` is keyword-only: `sorted(words, len)` raises `TypeError` because `sorted` takes exactly one positional argument. ## In review When you see a sort in a code review, three questions cover almost everything: is the key doing work that should be precomputed once outside the sort, is it total (no mixed types, no `None` holes), and does a tuple key say the multi-field intent more clearly than a chain of separate sorts? A well-chosen key turns an ordering rule into a single readable expression.
- How do you sort by one field descending and another ascending in a single pass?If the descending field is numeric, negate it inside the tuple key: `key=lambda r: (-r.score, r.name)`. If it is a string or date you cannot negate, either sort twice — least significant field first, relying on the guaranteed stability — or wrap the value in a small class that inverts its comparison. `reverse=True` cannot help, because it flips the entire ordering rather than one component.
- Why does a key function scale better than a pairwise comparator?The key runs exactly n times, because the sort precomputes every key and then orders those values. A comparator is invoked on each comparison, roughly n log n times, and each call crosses into Python-level code. For 100,000 elements that is about 100,000 calls versus 1.7 million, which is why Python 3 removed `cmp` and kept only `key`.
- What happens if a key returns None for some elements and an int for others?The sort raises `TypeError: '<' not supported between instances of 'NoneType' and 'int'` — and it may raise only intermittently, since it depends on whether two mismatched keys are ever actually compared for that input. The fix is to make the key total, typically by leading with a boolean: `key=lambda r: (r.score is None, r.score or 0)` pushes the missing values to one end deterministically.
- Is operator.itemgetter really faster than the equivalent lambda?Yes, measurably: `itemgetter` is implemented in C, so it avoids a Python-level function call and frame setup per element. On small lists the difference is noise; on hundreds of thousands of elements it is a real percentage of the sort. It also reads well for multi-field keys, since `itemgetter("dept", "name")` returns a tuple directly.
saying these in an interview costs you the question
- Says the key function runs on every comparison
- Thinks key receives two elements like a comparator
- Writes key=str.lower() instead of key=str.lower
- Expects reverse=True to reverse tied elements too
- Believes reverse=True can apply to only one tuple field
- Ignores that mixed-type keys raise TypeError