Skip to content
← All articles

total_ordering: six comparisons from two, and the NotImplemented rule

People return False instead of NotImplemented, which silently breaks reflected comparison and produces an asymmetric ==. Also: mypy 2.3 understands the decorator, so that advice is stale.

Define __eq__ and one of __lt__, __le__, __gt__, __ge__, and @functools.total_ordering fills in the other three by composing what you gave it.

Two costs, both documented: the derived methods are slower than hand-written ones (each is a call into a generated wrapper), and they produce more complex stack traces. Neither matters unless comparisons are on a hot path — sorting a million objects is exactly such a path, and there the hand-written four are worth it.

Return NotImplemented, not False

This is the mistake the decorator is famous for surfacing:

def __eq__(self, other: object) -> bool:
    if not isinstance(other, SemVer):
        return False           # WRONG
    return self._key() == other._key()

NotImplemented is a signal to the interpreter: “I do not know how to compare with that; ask the other operand.” Python then tries the reflected operation — other.__eq__(self) for ==, other.__gt__(self) for < — and only if that also declines does it fall back to identity comparison (for ==) or raise TypeError (for ordering).

Return False and you have unilaterally declared the objects unequal without giving the other type a chance. If someone writes a LooseVersion class that knows how to compare itself with your SemVer, then semver == loose is False while loose == semver is True. An asymmetric == breaks in, dict lookups, assertEqual, and every set operation, in ways that depend on which side of the operator the value landed on.

Done correctly:

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

SemVer(1,0,0) == "1.0.0" is then False (no reflected handler on str either, so Python falls back to identity), and SemVer(1,0,0) < "1.0.0" raises TypeError. Two different behaviours from one correct return value, which is exactly what you want: equality between unrelated types is a meaningful “no”, ordering between them is a bug.

💡__eq__ is annotated other: object but returns click to reveal

NotImplemented, which is not a bool. Why does --strict accept that? Because typeshed declares NotImplemented with the type _NotImplementedType, which is defined as a subclass of Any. It is assignable to any return type, including bool, by design — precisely so that the standard dunder idiom type-checks without a cast.

The other: object part is not optional, though. __eq__ is defined on object as taking object, so narrowing the parameter to SemVer in a subclass violates the Liskov substitution principle and --strict reports it. This is the right call: x == y must be legal for any two objects, and a narrowed __eq__ would make a legal expression a type error.

__lt__ is different — it is not inherited from object, so you are free to annotate it other: SemVer and have the checker reject mixed-type ordering statically. Whether you want that depends on whether third parties should be able to make their type orderable against yours.

When to use it, and when not

@dataclass(order=True) is usually the better tool. It generates all four ordering methods directly, comparing the fields as a tuple in declaration order, with no wrapper overhead, and field(compare=False) excludes a field from the comparison.

total_ordering earns its place when the ordering is not field-lexicographic. Semantic versioning is the canonical example: a pre-release sorts before its release, so 1.2.3-alpha < 1.2.3, while the field tuple (1, 2, 3, "alpha") sorts after (1, 2, 3, "") because the empty string is lexicographically smallest. No arrangement of fields fixes that; you need a computed key.

The clean shape is a private _key() method returning a tuple, used by both __eq__ and __lt__ (and by __hash__, which you must define yourself — defining __eq__ sets __hash__ to None unless you say otherwise).

The stale advice

“total_ordering confuses type checkers” was true for a long time and is not now — mypy 2.3 understands the decorator and knows that the derived methods exist. If a style guide in your codebase bans it on those grounds, the ban has expired.

💡Why does defining __eq__ set __hash__ = None, and what breaks click to reveal

if you forget to define __hash__ on a total_ordering class? Because the invariant “equal objects have equal hashes” is not something Python can verify, and inheriting object.__hash__ (which hashes on identity) after redefining equality on value would silently violate it: two equal objects would hash differently, so a dict would happily hold both as separate keys and x in some_set would return False for a value that is in the set.

Rather than let that happen, Python makes the class unhashable, and you get a loud TypeError: unhashable type at the first set() or dict key or functools.cache call. That is one of the better trade-offs in the data model: a guaranteed immediate failure instead of a silent correctness bug.

Define __hash__ as hash(self._key()) using the same key __eq__ uses, and only if the fields in that key are genuinely immutable. Mutating a field that feeds the hash while the object is in a set is the classic way to lose an element permanently.