Python Sort Custom Objects: Key Functions & __lt__
python sort custom objects: Sort custom Python objects by attribute or custom logic using key functions, attrgetter, and __lt__. Practical examples and performance notes.
When you need to python sort custom objects, the built-in sorted() and list.sort() functions rely on the < operator to compare elements. For user-defined classes, that operator is not defined by default, so sorting fails with a TypeError unless you provide an explicit ordering. There are two main approaches: supply a key function that extracts a sortable value from each object, or implement rich comparison methods on the class itself. Both are valid, but they serve different situations.
Why Sorting Custom Objects Requires a Key or Comparator
Consider a simple Product class with name and price attributes. Without any additional code, calling sorted(products) raises TypeError: '<' not supported between instances of 'Product' and 'Product'. The default comparison does not know how to order your objects. To sort, you need to tell Python what value to compare. The key function is the most direct way: it maps each object to a value that Python knows how to compare, such as a number or a string.
class Product: def __init__(self, name, price): self.name = name self.price = price
With this class, sorted(products) fails. The fix is to provide a key function that returns product.price or product.name. This is the standard approach for one-off sorting needs and for cases where you do not control the class definition.
Using sorted() and list.sort() with a Key Function
Both sorted() and list.sort() accept a key parameter. The key function is called once for each element, and the returned value is used for comparison. This avoids calling the comparison operator on the original objects directly.
products = [ Product("laptop", 1200), Product("mouse", 25), Product("keyboard", 80), ] by_price = sorted(products, key=lambda p: p.price) print([p.name for p in by_price]) # ['mouse', 'keyboard', 'laptop']
The lambda is convenient for simple attribute access. However, if the key expression is more complex or reused multiple times, a named function or operator.attrgetter is clearer. Note that list.sort() sorts in place and returns None, while sorted() returns a new list. Choose the one that matches whether you need to preserve the original list.
Sorting by a Single Attribute with operator.attrgetter
For simple attribute access, operator.attrgetter is more readable and often faster than a lambda because it is implemented in C. It returns a callable that fetches the given attribute from its argument.
from operator import attrgetter products.sort(key=attrgetter("price"))
This sorts the products list in place by price. You can also use it with sorted(). The attribute name is a string, so it works when the attribute is known at runtime. For nested attribute access, such as product.category.name, you can pass a dotted string to attrgetter, which will traverse the attributes.
sorted(products, key=attrgetter("category.name"))
This is cleaner than writing a lambda that accesses multiple attributes. If you need to sort by a computed value, such as a discounted price, a lambda or a regular function is more appropriate.
Sorting by Multiple Attributes
Often you need to sort by one attribute, then by another when the first values are equal. The key function can return a tuple of values. Python compares tuples element by element, so the first element determines order, and ties are broken by the second, and so on.
products.sort(key=lambda p: (p.category, p.price))
This sorts by category first, then by price within each category. For a descending order on one attribute and ascending on another, you can use the reverse parameter, but it reverses the entire sort. To mix directions, you can negate numeric values or use a custom comparator. For example, to sort by category ascending and price descending:
products.sort(key=lambda p: (p.category, -p.price))
This works only when price is numeric. For string attributes, you would need a different approach, such as using functools.cmp_to_key with a custom comparison function. That is more verbose but gives full control over the comparison logic.
Custom Comparison with lt and Total Ordering
If you control the class and want sorting to be a natural part of the object's behavior, implement __lt__ (less than) on the class. The sorted() and list.sort() functions use < to compare elements, so defining __lt__ is sufficient. However, other comparison operators like <=, >, and >= will not be consistent unless you also define them. The functools.total_ordering decorator can fill in the missing methods based on __lt__ and __eq__.
from functools import total_ordering @total_ordering class Product: def __init__(self, name, price): self.name = name self.price = price def __lt__(self, other): if not isinstance(other, Product): return NotImplemented return self.price < other.price def __eq__(self, other): if not isinstance(other, Product): return NotImplemented return self.price == other.price
Now sorted(products) works without a key. The comparison logic is encapsulated in the class, which is useful when the same ordering is used in many places. However, this approach mixes the sorting logic with the data model, which can reduce flexibility if you need different orderings in different contexts. For example, you might want to sort by name in one view and by price in another. In that case, key functions are more appropriate because they keep the ordering decision at the call site.
Stability and Reverse Sorting
Python's sort is stable, meaning that when two objects compare equal, their original order is preserved. This is important when sorting by multiple keys sequentially. For instance, you can sort by name first, then by price, and the price sort will not disturb the name order within equal prices because stability preserves the original sequence.
products.sort(key=attrgetter("name")) products.sort(key=attrgetter("price"))
The result is sorted by price, and within equal prices, the names remain sorted alphabetically because the first sort's order is preserved. This technique is often more efficient than sorting by a tuple when the secondary key is expensive to compute, though the tuple approach is simpler for a single pass.
The reverse parameter of sorted() and list.sort() reverses the final order. It does not reverse the key extraction. If you need a descending sort on a numeric attribute, you can either set reverse=True or negate the key. Negation is useful when you need mixed directions.
Performance and Maintainability Considerations
The key function is called exactly once per element, so its cost is proportional to the number of items. This is more efficient than a comparator that is called many times during the sort, because comparisons happen O(n log n) times. Therefore, using a key function is almost always faster than implementing a custom comparator with cmp_to_key. For large lists, the difference is noticeable.
When you use operator.attrgetter, it is implemented in C and is slightly faster than a Python lambda. However, the difference is usually negligible unless you are sorting very large collections. The bigger performance factor is the complexity of the key function itself. If the key involves expensive computations, consider precomputing the sort key and storing it in a temporary list, or using the key parameter with a memoized function.
Maintainability also matters. Defining __lt__ on a class makes the default sort behavior obvious, but it can be a trap if you later need a different ordering. Key functions keep the sorting logic local to the call site, which is often easier to read and change. For a one-off script, a lambda is fine. For a codebase where the same ordering is used in many places, a named function or attrgetter is clearer. The choice between key functions and __lt__ should be driven by whether the ordering is an intrinsic property of the class or a contextual requirement.