Multi-Key Sorting with Tuple Keys
Concepts covered: Lambda, Sorting, Splitting & Joining, Tuples, Unpacking
Tuples compare element by element from the left. Python finds the first position where 2 tuples differ and compares only the elements at that position; anything after it is never examined. That rule makes a tuple the natural key for a spec such as 'by artist, then by streams, then by track name': the first slot decides, the second slot only breaks ties in the first, and the third only breaks ties in both.
| Comparison | Result | Decided at |
|---|---|---|
| (1, 'b') < (2, 'a') | True | position 0, since 1 < 2 |
| (1, 'b') < (1, 'c') | True | position 1, since 'b' < 'c' |
| (1, 'b') < (1, 'b', 0) | True | the shorter tuple is a prefix |
| (False, 9) < (True, 0) | True | position 0, since False < True |
The last row matters later in this section: booleans compare as False < True, so a boolean in the first slot splits rows into 2 groups before any other field is consulted.
Mixed directions: negate the numbers
reverse=True flips the entire key, so it cannot make one slot descend while another ascends. For a numeric field, negate it inside the key: -r['streams'] turns the largest count into the smallest key value. The key (r['artist'], -r['streams'], r['track']) reads as artist from A to Z, most streamed first within each artist, and track name from A to Z when 2 tracks of one artist tie on streams.
catalog = [
{'artist': 'Nova', 'track': 'Halo', 'streams': 30},
{'artist': 'Kite', 'track': 'Drift', 'streams': 50},
{'artist': 'Nova', 'track': 'Ember', 'streams': 45},
{'artist': 'Nova', 'track': 'Aster', 'streams': 30},
{'artist': 'Kite', 'track': 'Pulse', 'streams': 50},
]
ordered = sorted(catalog, key=lambda r: (r['artist'], -r['streams'], r['track']))
for r in ordered:
print(r['artist'], r['streams'], r['track'])| Printed line | Key tuple | Why it lands there |
|---|---|---|
| Kite 50 Drift | ('Kite', -50, 'Drift') | Kite sorts before Nova |
| Kite 50 Pulse | ('Kite', -50, 'Pulse') | ties Drift on slots 0 and 1; 'Drift' < 'Pulse' |
| Nova 45 Ember | ('Nova', -45, 'Ember') | -45 < -30, so the larger count comes first |
| Nova 30 Aster | ('Nova', -30, 'Aster') | ties Halo on slots 0 and 1; 'Aster' < 'Halo' |
| Nova 30 Halo | ('Nova', -30, 'Halo') | last on every slot |
Each row of output is explained by the first slot where its key differs from its neighbour's. Working an interview answer this way, slot by slot, catches a wrong sign before the code runs.
Fields that cannot be negated
Negation needs a number. -'Kite' raises TypeError: bad operand type for unary -: 'str', and a datetime.date refuses unary minus in the same way. Three workarounds cover most interview cases:
- Dates and timestamps: convert to a number inside the key. -d.toordinal() or -ts.timestamp() sorts newest first.
- Every slot in the same direction: skip negation and pass reverse=True for the whole tuple.
- A string slot must descend: sort in 2 stable passes, the technique the next section builds.
Missing values with a boolean slot
Real columns contain None, and a single one stops a plain sort. Put a boolean in front of the field: s is None is False for rows with a value and True for rows without, and since False < True, missing values land last. For the numeric slot, -(s or 0) turns a missing value into 0, so None never enters the key at all, and the leading boolean keeps those placeholder zeros apart from real counts. The shorter key (s is None, s) would also work: 2 None slots compare equal with ==, so the comparison moves on to the next slot. A None meets < only when it sits in the first slot that differs, and the boolean in front rules that out.
rows = [
{'track': 'Halo', 'streams': 30},
{'track': 'Glow', 'streams': None},
{'track': 'Drift', 'streams': 50},
{'track': 'Aura', 'streams': None},
]
def nulls_last_desc(r):
s = r['streams']
return (s is None, -(s or 0), r['track'])
result = sorted(rows, key=nulls_last_desc)
print([r['track'] for r in result])This prints ['Drift', 'Halo', 'Aura', 'Glow']. Drift and Halo carry False and order by descending streams; Aura and Glow carry True, tie on the 0 placeholder, and fall back to track name. To put missing values first instead, lead with s is not None. Writing the key as a named function, as here, also gives a reviewer one place to read the full ordering rule.
About this lesson
This section is part of the Custom Sorting: Beginner lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.