All posts

How to Implement Comparators (Greater than, Less than, Equal to) for your Python Classes

You have written a custom python class. Here is how to implement logic to check if instances of the class are equal, less than etc.

As applications grow, you end up writing classes that carry data around - a record fetched from a database, a version number, a job in a queue. Sooner or later you want to ask whether two of them are the same, or which one comes first. Python does not guess that for you.

Two instances with the same data are not equal by default

By default, == on a custom class compares identity, not contents. Two objects holding identical values are still two different objects:

python
class Doc:    def __init__(self, doc_id, title, version):        self.doc_id, self.title, self.version = doc_id, title, version
x = Doc("d1", "Quarterly report", 2)y = Doc("d1", "Quarterly report", 2)
x == y   # Falsex == x   # True

That default is often wrong for data-carrying classes. If you fetch the same document twice, you want the two objects to compare equal.

Dataclasses are the exception - @dataclass writes an __eq__ for you that compares every field:

python
from dataclasses import dataclass
@dataclassclass Document:    doc_id: str    title: str    version: int
Document("d1", "Quarterly report", 2) == Document("d1", "Quarterly report", 2)   # True

If a dataclass gives you what you want, stop there. The rest of this post is for when it does not - when only some fields should count, or when you need ordering as well.

Implementing __eq__

== calls __eq__. Define it to compare whatever actually identifies your object:

python
class Document:    def __init__(self, doc_id, title, version):        self.doc_id, self.title, self.version = doc_id, title, version
    def __eq__(self, other):        if not isinstance(other, Document):            return NotImplemented        return self.doc_id == other.doc_id and self.version == other.version

Here the title is deliberately excluded: a document is the same document at the same version even if someone renamed it.

python
a = Document("d1", "Quarterly report", 2)b = Document("d1", "A different title", 2)c = Document("d1", "Quarterly report", 3)
a == b        # True  - title is not part of identitya == c        # False - different versiona != c        # True  - Python derives != from __eq__a == "d1"     # False

Two details worth noticing.

You do not write __ne__. Python derives != from __eq__ automatically.

Return NotImplemented, not False, when the other object is not your type. NotImplemented tells Python "I don't know how to compare these", so it asks the other object whether it knows. False asserts the two are definitely unequal, which forecloses that. In the example above a == "d1" still ends up False, but only after str has been given its turn.

Defining __eq__ makes your class unhashable

This is the part that catches people. As soon as you define __eq__, Python sets __hash__ to None:

python
{a}   # TypeError: unhashable type: 'Document'

Your objects can no longer go in a set or be used as dict keys. Python does this on purpose: objects that compare equal must hash equal, and it will not assume your new equality rule still matches the default hash.

Fix it by defining __hash__ over the same fields __eq__ uses:

python
class Document:    def __init__(self, doc_id, version):        self.doc_id, self.version = doc_id, version
    def __eq__(self, other):        if not isinstance(other, Document):            return NotImplemented        return (self.doc_id, self.version) == (other.doc_id, other.version)
    def __hash__(self):        return hash((self.doc_id, self.version))

Now the de-duplication you probably wanted in the first place works:

python
a, b = Document("d1", 2), Document("d1", 2)
len({a, b})    # 1b in {a}       # True

Hashing a tuple of the fields is the standard approach. Only hash on values that do not change while the object is in a set - if you mutate version after inserting, you will not find the object again.

Ordering: __lt__ and friends

Ordering is four more methods - __lt__, __le__, __gt__, __ge__. You rarely need to write all four. functools.total_ordering fills in the rest from __eq__ and __lt__:

python
from functools import total_ordering
@total_orderingclass Version:    def __init__(self, major, minor):        self.major, self.minor = major, minor
    def __eq__(self, other):        if not isinstance(other, Version):            return NotImplemented        return (self.major, self.minor) == (other.major, other.minor)
    def __lt__(self, other):        if not isinstance(other, Version):            return NotImplemented        return (self.major, self.minor) < (other.major, other.minor)
    def __hash__(self):        return hash((self.major, self.minor))
    def __repr__(self):        return f"{self.major}.{self.minor}"

Comparing tuples of fields gives you the field-by-field precedence you usually want: major first, minor only as a tiebreak.

python
versions = [Version(1, 4), Version(0, 9), Version(1, 10), Version(1, 2)]
sorted(versions)              # [0.9, 1.2, 1.4, 1.10]max(versions)                 # 1.10Version(1, 4) > Version(0, 9) # TrueVersion(1, 2) <= Version(1, 4)# True

Note 1.10 sorts after 1.4, because these are integers being compared, not a decimal number. That is what you want from a version, and it is the reason to compare structured fields rather than a string.

__lt__ is enough to unlock sorted(), min() and max() on its own. The other three matter when your objects are compared directly with >=, <= and >.

Why NotImplemented matters more for ordering

With equality, a sloppy implementation returns a wrong answer. With ordering, it raises a confusing one. Compare an implementation that guards its type:

python
Version(1) < 5# TypeError: '<' not supported between instances of 'Version' and 'int'

against one that assumes other is the right type and reaches for other.major:

python
Loose(1) < 5# AttributeError: 'int' object has no attribute 'major'

The first message names the real problem. The second leaks your implementation and sends the reader looking in the wrong place.

Let dataclasses do it where you can

@dataclass covers most of this with arguments rather than methods:

python
from dataclasses import dataclass, field
@dataclass(order=True)          # writes __lt__, __le__, __gt__, __ge__class Version:    major: int    minor: int
sorted([Version(1, 4), Version(0, 9), Version(1, 10)])# [Version(major=0, minor=9), Version(major=1, minor=4), Version(major=1, minor=10)]

frozen=True makes instances immutable and restores hashing, so they work as dict keys and set members:

python
@dataclass(frozen=True)class DocKey:    doc_id: str    version: int
len({DocKey("d1", 2), DocKey("d1", 2)})   # 1

And field(compare=False) excludes a field from both equality and ordering - the dataclass equivalent of leaving title out of __eq__ above:

python
@dataclassclass Document:    doc_id: str    version: int    title: str = field(default="", compare=False)
Document("d1", 2, "Quarterly report") == Document("d1", 2, "A different title")   # True

With order=True, the comparison runs over the fields in declaration order, so put the field you want to sort by first.

Summary

  • == compares identity by default. Define __eq__ to compare contents; you never need __ne__.
  • Return NotImplemented for types you do not handle, so Python can try the other operand and so the error message is the right one.
  • Defining __eq__ sets __hash__ to None. Define __hash__ over the same fields if the object goes in a set or a dict.
  • Compare a tuple of fields. It gives field-by-field precedence for free and keeps __eq__ and __hash__ consistent.
  • functools.total_ordering derives __le__, __gt__ and __ge__ from __eq__ and __lt__.
  • Reach for @dataclass first: order=True, frozen=True and field(compare=False) cover most cases without any of the above.

Every snippet here was run on Python 3.10.

Related posts

All posts

Read the Newsletter.

I write a monthly newsletter on Applied AI and HCI. Subscribe to get notified on new posts.

Feel free to reach out! Twitter, GitHub, LinkedIn

Read and Subscribe