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.