skip to content

How does Python compare two tuples with <, and how does that drive sorted()?

level: middleimportance: should knowfreq 52%

answer

  1. Order them the way a dictionary orders words
  2. Only the first difference matters
  3. A prefix loses to a longer sequence
  4. Later positions are never examined
  5. Build the sort key as several fields

basics

~10 s

Tuples compare element by element: the first unequal pair decides, and a prefix is the smaller tuple. sorted() uses that ordering, so a key function returning a tuple sorts by several fields.

solid answer

~40 s

Tuple comparison is **lexicographic**, like words in a dictionary. Python walks both tuples in parallel, and the first position where the elements are not equal decides the whole comparison; positions after it are never examined. If every compared position is equal, the shorter tuple is the smaller one, so `('a',) < ('a', 0)`. Because elements are compared with their own operators, a differing pair of incomparable types raises `TypeError` — `('a', 1) < ('a', 'b')` fails at position 1, though it would never have been reached if position 0 differed. This is exactly why multi-field sorting is written as `sorted(rows, key=lambda r: (r[0], -r[1]))`: the key builds a tuple, and lexicographic order gives you primary, then secondary, then tertiary ordering with no comparator function.

code

python · 7 lines
python
print(("a", 10) < ("b", 2))
print(("a",) < ("a", 0))
print(sorted([("b", 2), ("a", 3), ("a", 1)]))
try:
    ("a", 1) < ("a", "b")
except TypeError as exc:
    print("TypeError:", exc)

go deeper

for a junior

Know that tuples sort like words in a dictionary and that sorted() on a list of tuples orders by the first element, then the second. Be able to predict a small example out loud.

for a middle

Explain the exact rule - first unequal position decides, prefix is shorter-is-smaller - and write a multi-field sort key that mixes an ascending and a descending field.

for a senior

Show that you know where this bites: positional TypeErrors that only appear on certain data, tie-breaking in a heap-based priority queue, and using stability to sort in two passes rather than inventing a comparator.

for a principal

Own the ordering contract itself - which fields define a canonical, reproducible order for paging, deduplication and diffable output, and how that order stays stable when new fields appear.

### The algorithm, precisely Comparing two tuples with `<`, `>`, `<=` or `>=` runs a lexicographic comparison, the same rule that orders words in a dictionary. Python steps through both tuples in parallel: 1. At each position it asks whether the two elements are equal. 2. At the **first position where they are not equal**, it compares those two elements with the ordering operator and returns that answer. Every later position is ignored. 3. If it runs out of elements with everything so far equal, length decides: the shorter tuple is smaller. `('a',) < ('a', 0)` is `True`. Equality (`==`) works the same way but requires equal lengths and all positions equal. Two details of the walk matter in practice. First, the equality step uses an identity shortcut: an element that *is* the same object is treated as equal without calling `__eq__`. That is why `t = (float('nan'),)` satisfies `t == t` even though `float('nan') == float('nan')` is `False` — the tuple compares the very same object with itself. Second, because element comparison is delegated to the elements, a heterogeneous pair fails loudly: ```python ('a', 1) < ('a', 'b') # TypeError: '<' not supported between 'int' and 'str' ``` Crucially this is *positional*. The same two tuples compare fine if an earlier position already differs, so a type problem in your data can hide for a long time and then surface on one unlucky pair of records. **Python 3 removed Python 2's fallback ordering between unrelated types**, which is what turned this from a silently arbitrary answer into an exception; the behaviour is unchanged in 3.14. ### Why this makes tuples the multi-field sort key `sorted()` and `list.sort()` take a `key` callable and order the results by the keys' own ordering. Return a tuple from the key and lexicographic comparison gives you multi-field sorting for free: ```python sorted(rows, key=lambda r: (r[0], -r[1])) # first field up, second field down ``` Three techniques follow from that: * **Descending on one field** — negate a numeric field in the key, as `-r[1]` above. `reverse=True` cannot help here because it flips *every* field at once. * **Non-numeric descending** — you cannot negate a string, so sort in two stable passes instead: sort by the secondary field first, then by the primary. Python's sort is guaranteed **stable**, meaning records that compare equal keep their previous relative order, so the second pass preserves the first one's work. * **`operator.itemgetter`** — `key=itemgetter(1, 0)` returns a tuple of the named positions and is usually faster than the equivalent lambda, because the extraction happens in C. The same ordering powers `min()`, `max()` and `heapq`. Pushing `(priority, item)` tuples onto a heap gives a priority queue in one line — and exposes the classic trap: when two priorities tie, the heap falls through to comparing the items themselves, and if those are not orderable you get a `TypeError` at some unpredictable moment under load. The standard fix is a tie-breaker in the middle of the key, such as an always-increasing counter, so no comparison ever reaches the payload. ### Ordering is not equality One asymmetry catches people out. `==` between tuples requires equal lengths and equality at every position, and it never raises for mismatched types — unequal is a perfectly good answer. Ordering has to produce a *direction*, so it must actually compare the differing pair, and that is where `TypeError` comes from. `(1, 'x') == (1, 2)` is simply `False`; `(1, 'x') < (1, 2)` raises. When you only need to know whether two records match, comparing with `==` sidesteps the whole problem. The same rule orders a dict's entries when you sort them, since `dict.items()` yields `(key, value)` pairs: sorting them orders by key first and falls through to the values only on a tie — which quietly raises if the values are unorderable. Passing an explicit key that selects the field you meant, rather than sorting the pairs whole, avoids depending on the fall-through at all. ### Cost and readability Sorting compares keys, not records, and the key function is called exactly once per element — so building a small tuple in the key is cheap even for large inputs. What is *not* cheap is putting expensive work inside the key function, since it runs once per element regardless. For readability, prefer a key that names its fields over one dense expression, and be explicit about direction. A key like `(-total, name)` reads as "biggest total first, ties broken alphabetically", which is precisely the sentence a reviewer wants; two negations and a `reverse=True` in the same call is a bug waiting to happen. ### The interview one-liner Lexicographic means first difference wins, and a prefix is smaller. That single rule explains tuple ordering, multi-field sort keys, the negation trick for descending numeric fields, why a stable sort lets you sort in two passes, and why heap tie-breaks blow up on unorderable payloads.

  • How do you sort ascending by one field and descending by another when neither is numeric?
    You cannot negate a string, so use two stable passes: sort by the secondary field in its direction first, then sort by the primary field. Python's sort is guaranteed stable, so records equal on the primary field keep the order the first pass gave them. `reverse=True` is no help because it reverses the whole ordering, not one field.
  • Why can pushing (priority, item) tuples onto a heapq queue blow up in production?
    When two priorities tie, comparison falls through to the second position and compares the payloads. If those are objects with no ordering, you get a TypeError - and only on the unlucky tie, so it survives testing and fails under load. Insert a monotonically increasing counter as a middle element so the comparison never reaches the payload.
  • Why does a tuple holding a NaN compare equal to itself?
    Tuple comparison shortcuts on identity: if two positions hold the same object, they are treated as equal without calling __eq__. So `t == t` is True for `t = (float('nan'),)`, while comparing two separately created NaN values directly is False. It is a container-level optimisation, not a change to NaN semantics.

It is alphabetical order for sequences: 'car' before 'cat' is settled at the third letter, and 'car' before 'carpet' because the shorter word runs out first.

saying these in an interview costs you the question

  • Thinks tuples compare by length first
  • Says all elements are compared, not just the first difference
  • Believes reverse=True can flip a single field
  • Assumes mixed-type tuples always raise on comparison
  • Thinks the key function runs once per comparison
  • Claims Python's sort is not stable

context