Skip to content
← Back to explainers

Python explainer

Make Objects Sortable with total_ordering

About 3 min to read. Allow extra time to work through examples.

Write equality and one ordering method, let total_ordering add the rest, return NotImplemented for other types, and hash with the same key.

This article uses Python. You can make it your practice mode to browse related content across the site.

You want to sort release versions so that 1.2.3-rc.1 comes before 1.2.3. To sort your objects and compare them with <, Python needs methods such as __lt__ on the class. Write __eq__ and __lt__, and the functools.total_ordering decorator adds __le__, __gt__ and __ge__ from them:

from dataclasses import dataclass
from functools import total_ordering

@total_ordering
@dataclass(frozen=True, eq=False)
class Version:
    major: int
    minor: int
    patch: int
    pre: str = ""

    def _key(self) -> tuple[int, int, int, int, str]:
        return (self.major, self.minor, self.patch, 0 if self.pre else 1, self.pre)

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Version):
            return NotImplemented
        return self._key() == other._key()

    def __lt__(self, other: object) -> bool:
        if not isinstance(other, Version):
            return NotImplemented
        return self._key() < other._key()

    def __hash__(self) -> int:
        return hash(self._key())

preview = Version(1, 2, 3, "rc.1")
release = Version(1, 2, 3)
print(preview < release, release >= preview)  # True True
print(release == Version(1, 2, 3))            # True
print(len({release, Version(1, 2, 3)}))       # 1

Put the order in one key method

The _key() method returns a tuple, and Python compares tuples item by item. The fourth item is 0 for a pre-release and 1 for a release, so with the same numbers, a pre-release sorts first. Without that item, the empty tag of a release would sort before "rc.1", which gives the opposite order.

The eq=False option stops the dataclass from writing its own __eq__, which would compare the raw fields. The frozen=True option stops anyone from changing the fields after construction.

The total_ordering decorator does not write __eq__, and it does not check that your rules make sense. You get != from __eq__ without writing it.

This is a simplified version format, not full Semantic Versioning. It compares tags as plain strings, so "rc.10" sorts before "rc.2". Real Semantic Versioning compares the numeric parts of a tag as numbers.

Return NotImplemented for other types

When other is not a Version, return NotImplemented. That tells Python this method cannot handle the pair, so Python tries the other object's method. If neither can, == falls back to checking identity and gives False, and < raises TypeError:

print(release == "1.2.3")  # False
try:
    release < "1.2.3"
except TypeError as error:
    print(error)  # '<' not supported between instances of 'Version' and 'str'

Returning False instead would stop Python from asking the other object. That matters when the other object knows how to compare itself with yours.

Check your understanding Why is other annotated as object and not Version? Show answer

Python can call __eq__ with any object, as in release == "1.2.3". The base class object declares __eq__(self, other: object), and mypy rejects an override that accepts less. Keep -> bool as the return type. As a special case, mypy accepts NotImplemented there.

Hash with the same key as equality

When a class defines __eq__ but not __hash__, Python sets its __hash__ to None. Its instances then cannot go in a set or be dictionary keys. Add a __hash__ that hashes the same key that __eq__ compares. Equal objects then have equal hashes, which sets and dictionaries rely on.

The hash must also stay the same while the object is in a set. So the fields in the key must not change, and that is what frozen=True is for.

Choose the simplest design

  • If comparing the fields in the order you declared them is right, use @dataclass(order=True) and write no comparison methods.
  • If you sort in only one place, pass a key function, as in sorted(versions, key=version_key).
  • Use total_ordering with a key method when the type has its own rule and many places compare it.

Going further

The Semantic Versioning specification gives the full rules for comparing versions.

step 36 of 55

Everyday Standard Library Tools

Up next Sort Version Strings with Pre-Releases First