A tuple key handles several fields in one pass — until two of them need to go opposite ways.
rows = [{'team': 'b', 'score': 1}, {'team': 'a', 'score': 2}]
# Same direction: one pass, one tuple
sorted(rows, key=lambda r: (r['team'], r['score']))
# Opposite directions: you cannot negate a string
sorted(rows, key=lambda r: (r['team'], -r['score'])) # works for numbers only
sorted(rows, key=lambda r: (-r['team'], r['score'])) # TypeError
# Stable sort: sort by the last key first, then the one before it
result = sorted(rows, key=lambda r: r['score'], reverse=True)
result = sorted(result, key=lambda r: r['team'])
- Stability is what makes successive passes work
A stable sort never reorders records that compare equal, so sorting by score and then by team leaves records with the same team still in score order. Each pass refines the one before it rather than destroying it. That guarantee is documented in Python and not universal — C’s qsort and Java’s Arrays.sort on primitives are not stable — so it is worth knowing you are relying on it.
- Last key first
The passes run in reverse order of priority: the least significant field is sorted first and the most significant last, because the final sort is the one whose order survives at the top level. Getting this backwards produces output that looks sorted and is ordered by the wrong column, which is remarkably hard to spot in a table.
- The tuple trick has a type restriction
Negating the key is the classic way to reverse one field inside a tuple, and it only works for numbers. Strings, dates and None have no unary minus, so the moment somebody sorts by name descending and department ascending, the clever one-liner raises TypeError. Successive passes have no such limit.
Do not try to do it in one sorted() call. Sort once per key, and think carefully about which key you sort by first.
Walk the keys in reverse, sorting the result of the previous pass each time. For each key, strip a leading '-' to get the field name and remember whether it was there, so you can pass reverse=True.
key.startswith('-') gives the direction and key[1:] gives the field. Start from a copy — list(records) — so the empty-keys case still returns something new. Validate every field against every record before any sorting happens.
def sort_records(records, keys): for key in keys: field = key[1:] if key.startswith('-') else key for record in records: if field not in record: raise ____(f’record is missing {field!r}') result = ____(records) for key in ____(keys): descending = key.startswith('-') field = key[1:] if descending else key result = sorted(result, key=lambda r: r[field], ____=descending) return result
def sort_records(records, keys):
for key in keys:
field = key[1:] if key.startswith("-") else key
for record in records:
if field not in record:
raise ValueError(
f"record is missing {field!r}: {record!r}"
)
result = list(records)
# Least significant key first: each pass is stable, so the earlier
# ordering survives inside the ties of the later one.
for key in reversed(keys):
descending = key.startswith("-")
field = key[1:] if descending else key
result = sorted(result, key=lambda record: record[field],
reverse=descending)
return result
Sorting k times is O(k · n log n), and the tuple version is O(n log n) with a comparison k times as expensive — the same work, arranged differently. Python’s sort is also unusually good at nearly-ordered data, which every pass after the first hands it. The reason to prefer one over the other is expressiveness, not speed.
field is rebound each time round the loop, and the lambda is used immediately inside that iteration rather than stored for later — so it always sees the value it was written next to. Build a list of these lambdas instead of calling them straight away and you have the late-binding bug from the discount-buttons exercise.
The check loops over all keys and all records first, so a record missing the *second* key fails before the first pass has moved anything. Doing it lazily would mean the function sometimes raises after having already done half the work — harmless here because the result is a copy, and a real problem in any version that sorted in place.
If every field sorts the same way, one sorted() with a tuple key is clearer and should be preferred — reach for passes when the directions differ. If the data lives in a database, ORDER BY does this where the indexes are, and pulling every row into Python to sort it is the expensive mistake. And for a table the user re-sorts repeatedly, sort in the front end rather than round-tripping to the server. A SortSpec of (field, direction) pairs is worth more than the '-name' convention once field names can legitimately start with a minus.
- Handle a missing field as 'sorts last' instead of an error, and work out how to express that when the direction is descending.
- Accept a key function per field, so dates stored as strings sort as dates, and see whether the '-' convention still fits.
- Compare against sorted() with a tuple key on a large list of records and find out whether the difference is measurable at all.