Home Tutorials Built-ins

Built-ins

Python Sorting: sorted(), sort(), and Custom Keys

Pyford Notes July 6, 2026 6 min read
Key points
  • sorted() returns a new list and works on any iterable; list.sort() sorts a list in place and returns None.
  • key= takes a function applied to each element before comparison — sort by the key's result, not the raw value.
  • For multiple sort criteria, return a tuple from the key function; earlier tuple items take priority.
  • Python's sort is stable: elements that compare equal keep their original relative order.

sorted() vs list.sort()

Python provides two ways to sort: the built-in function sorted(), which works on any iterable and returns a new list, and the list.sort() method, which sorts a list in place and returns None:

numbers = [4, 1, 3, 2]

new_list = sorted(numbers)
print(new_list)   # [1, 2, 3, 4]
print(numbers)    # [4, 1, 3, 2]  -- unchanged

numbers.sort()
print(numbers)    # [1, 2, 3, 4]  -- changed in place

A common mistake is writing numbers = numbers.sort(), which sets numbers to None because .sort() does not return the sorted list. Use sorted() when you need a new list or are sorting a non-list iterable like a dict's keys or a generator; use .sort() when you specifically want to mutate an existing list.

Sorting with a key function

The key parameter takes a function that is applied to each element before comparison; the elements themselves are sorted, but the comparison happens on what the key function returns:

words = ["banana", "kiwi", "apple", "fig"]
print(sorted(words, key=len))          # ['kiwi', 'fig', 'apple', 'banana']
print(sorted(words, key=str.lower))    # case-insensitive alphabetical order

The key function runs once per element, not once per comparison, so it is efficient even on large lists with expensive key computations — Python caches each computed key rather than recomputing it on every pairwise comparison.

Sorting by multiple criteria

To sort by more than one attribute, have the key function return a tuple. Tuples compare element by element, so the first tuple item is the primary sort criterion and later items only break ties:

people = [
    {"name": "Sam", "age": 34},
    {"name": "Ana", "age": 28},
    {"name": "Ana", "age": 34},
]

by_age_then_name = sorted(people, key=lambda p: (p["age"], p["name"]))
for p in by_age_then_name:
    print(p["age"], p["name"])
# 28 Ana
# 34 Ana
# 34 Sam

The two age-34 entries are ordered by name only because their ages tie; if ages were all distinct, the name would never come into play.

Reversing the order

Both sorted() and .sort() accept a reverse=True keyword argument, which sorts in descending order without needing to negate the key or reverse the list afterward:

print(sorted([3, 1, 4, 1, 5], reverse=True))   # [5, 4, 3, 1, 1]

When combining reverse=True with a multi-part tuple key, all parts reverse together. If you need one field ascending and another descending in the same sort, negate the numeric key component instead of using reverse=True — for example, key=lambda p: (-p["age"], p["name"]) sorts age descending and name ascending in a single pass.

Sort stability

Python's sort algorithm (Timsort) is stable: when two elements compare equal under the given key, their relative order from the original sequence is preserved. This guarantee is what makes multi-pass sorting reliable — sort by the least important criterion first, then by the most important, and the earlier ordering survives as the tie-breaker:

rows = [("b", 2), ("a", 1), ("c", 2), ("d", 1)]
step1 = sorted(rows, key=lambda r: r[0])          # sort by letter first
step2 = sorted(step1, key=lambda r: r[1])         # then by number
print(step2)   # [('a', 1), ('d', 1), ('b', 2), ('c', 2)]

Within each number group, the letters stay alphabetically ordered because that ordering was already in place before the second sort ran, and stability guarantees it was not disturbed.

operator.itemgetter and attrgetter

A lambda key works everywhere, but the operator module offers ready-made key functions for the two most common cases: pulling a dictionary or sequence item, and reading an object attribute. Both are typically a little faster than an equivalent lambda, since they skip the overhead of an extra Python-level function call:

from operator import itemgetter, attrgetter

people = [{"name": "Sam", "age": 34}, {"name": "Ana", "age": 28}]
print(sorted(people, key=itemgetter("age")))

class Person:
    def __init__(self, name, age):
        self.name, self.age = name, age

records = [Person("Sam", 34), Person("Ana", 28)]
print([p.name for p in sorted(records, key=attrgetter("age"))])   # ['Ana', 'Sam']

itemgetter can also take multiple keys at once — itemgetter("age", "name") builds the same kind of multi-field tuple that a hand-written lambda p: (p["age"], p["name"]) would, without writing the lambda yourself. attrgetter supports the same multi-field form, including dotted paths like attrgetter("address.city") for nested attribute access, which a plain lambda can express too but slightly more verbosely. Both are drop-in replacements wherever a key function is expected, including as the key argument to min() and max(), not just sorted().