Home Tutorials OOP

OOP

Operator Overloading in Python: Dunder Methods Like __add__ and __eq__

Pyford Notes July 6, 2026 7 min read
Key points
  • Operators like +, ==, and < are shorthand for calls to dunder methods such as __add__, __eq__, and __lt__.
  • Defining these methods on your own class lets instances work with the built-in operators instead of requiring named methods for every operation.
  • __repr__ is for developers and debugging; __str__ is for readable output shown to users.
  • Returning NotImplemented (not raising an error) from a dunder method lets Python fall back to the other operand's reflected method.

What dunder methods are

"Dunder" is short for "double underscore" — methods named like __init__ or __add__, with two underscores on each side. Python calls these methods automatically in response to syntax and built-in functions, rather than you calling them by name. When you write a + b, Python is really evaluating type(a).__add__(a, b). This is why + works differently for numbers, strings, and lists — each type provides its own __add__ that defines what addition means for it.

Defining these methods on your own classes is called operator overloading: giving existing operators new meaning for a new type.

Arithmetic operators: __add__ and friends

Suppose you have a small class representing a length measured in the same unit. Adding two lengths together should produce a new length:

class Length:
    def __init__(self, meters):
        self.meters = meters

    def __add__(self, other):
        return Length(self.meters + other.meters)

    def __repr__(self):
        return f"Length({self.meters}m)"

a = Length(3)
b = Length(4)
print(a + b)   # Length(7m)

The same pattern applies to other operators: __sub__ for -, __mul__ for *, __truediv__ for /, and so on. You only implement the ones that make sense for your type — multiplying two Length objects together does not have an obvious meaning, so a Length class might reasonably leave __mul__ undefined.

Comparisons: __eq__ and __lt__

Without a custom __eq__, Python's default equality check compares identity — whether two variables point to the exact same object, not whether their contents match:

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

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)   # False -- default __eq__ compares identity

Defining __eq__ fixes this by comparing the attributes that actually matter for equality:

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

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

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)   # True

Ordering comparisons work the same way through __lt__, __le__, __gt__, and __ge__. Rather than implementing all four by hand, the functools.total_ordering decorator can fill in the rest once you supply __eq__ and one of the ordering methods.

__repr__ vs __str__

Both control how an object is turned into text, but for different audiences. __repr__ should produce an unambiguous, ideally code-like representation useful for debugging and logging; __str__ should produce something readable, aimed at an end user reading normal program output:

class Money:
    def __init__(self, amount):
        self.amount = amount

    def __repr__(self):
        return f"Money(amount={self.amount})"

    def __str__(self):
        return f"${self.amount:.2f}"

m = Money(9.5)
print(str(m))    # $9.50
print(repr(m))   # Money(amount=9.5)
print(m)         # $9.50 -- print() uses __str__

If you only define __repr__, Python falls back to it for str() as well, so it is common to implement __repr__ on every class and add __str__ only when a friendlier display format is genuinely useful.

Reflected methods and NotImplemented

When Python evaluates a + b and a's __add__ does not know how to handle b's type, it should return the special value NotImplemented rather than raising an exception. Python then tries b's reflected method, __radd__, giving the other operand a chance to handle the operation:

class Length:
    def __init__(self, meters):
        self.meters = meters

    def __add__(self, other):
        if not isinstance(other, Length):
            return NotImplemented
        return Length(self.meters + other.meters)

If both __add__ and the reflected __radd__ return NotImplemented, Python raises a clear TypeError on your behalf, rather than your code raising a confusing error partway through an operation it should never have attempted.

When operator overloading helps — and when it hurts

Operator overloading earns its keep for types that model mathematical or naturally comparable values — vectors, money, durations, custom numeric types. It becomes a liability when the operator's meaning is not obvious: overloading + on a class to mean something unrelated to combination or addition will confuse anyone reading the code later, including a future version of yourself. A useful test before implementing an operator: would a reader unfamiliar with your class correctly guess what the operator does just from ordinary Python conventions? If not, a plainly named method is the better choice.

It is also worth being deliberate about which dunder methods you implement together. Defining __eq__ without also giving thought to __hash__ can produce a class that compares as equal to another instance yet hashes differently — a combination that breaks the assumption dictionaries and sets rely on, since Python's data model states that objects which compare equal should also produce the same hash. By default, defining a custom __eq__ on a class sets its __hash__ to None, making instances unhashable, which is usually the safest default unless you have specifically thought through what hashing that class should mean.