How do you implement operator overloading correctly, and what pitfalls come with `__eq__`/`__hash__`?

6 minadvancedoopoperator-overloadingeq-hash

Quick Answer

Implement the relevant dunder method (`__add__`, `__lt__`, `__eq__`, etc.) and return `NotImplemented` (not raise an error) when the other operand's type isn't supported, so Python can try the reflected method (`__radd__`) or the other object's comparison. The biggest pitfall: overriding `__eq__` without also defining `__hash__` makes instances **unhashable**, since Python sets `__hash__ = None` automatically whenever `__eq__` is customized.

Detailed Answer

Returning NotImplemented, not raising

class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented    # let Python try other.__radd__(self)
        return Vector(self.x + other.x, self.y + other.y)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

Vector(1, 2) + Vector(3, 4)   # Vector(4, 6)
Vector(1, 2) + 5               # TypeError: unsupported operand type(s)

Returning NotImplemented (a sentinel, not an exception) tells Python "I don't know how to handle this operand" — Python then tries the right-hand operand's __radd__, and only raises TypeError if that also fails. Raising an exception directly from __add__ would skip that fallback and could produce a worse error, or wrongly prevent a valid reflected operation.

The __eq__/__hash__ contract

Python's rule: if two objects are equal (a == b), they must have the same hash, because dicts/sets rely on this to find matching keys.

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __eq__(self, other):
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)

hash(Point(1, 2))   # TypeError: unhashable type: 'Point'

The moment you define __eq__, Python sets __hash__ = None on the class automatically, because the inherited identity-based __hash__ from object would now violate the contract (two equal-by-value Points would have different hashes, breaking dict/set lookups).

The fix

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, other):
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)

    def __hash__(self):
        return hash((self.x, self.y))   # consistent with __eq__

Hash based on exactly the same fields used in __eq__ — this guarantees equal objects hash equally. If a class is meant to be mutable and therefore intentionally unhashable (a common convention: mutable objects shouldn't be dict keys, since mutating them after insertion would corrupt the hash table), it's fine to leave __hash__ as None deliberately — just make that choice consciously rather than by accident.

Rich comparison methods

For ordering, implement __lt__, __le__, __gt__, __ge__ individually, or use functools.total_ordering to derive the rest from just __eq__ and one of them.

Interview-ready summary: Dunder operator methods should return NotImplemented (never raise) for unsupported operand types, so Python can fall back to the reflected method. Defining __eq__ silently disables the default __hash__; if instances need to be hashable, define __hash__ explicitly using the same fields as __eq__.