Operator Overloading

This section documents the current Python release line as published at the official Python documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Python does not have operator overloading via a `/`==` keyword table -- it has *dunder methods* (double-underscore names such as `__init__` or `__add__`) that every built-in operator, function, and piece of syntax dispatches to. Implement the right dunder and a custom class gets `repr()`, `==`, `len()`, `obj[i]`, `for x in obj`, `obj()`, `with obj:`, and ` for free. The full catalogue is the data model’s special method names reference.

Construction and Representation

init initializes a new instance (construction itself is new, rarely overridden). repr should return an unambiguous, ideally re-evaluable string aimed at developers; str returns a human-readable string and falls back to repr if not defined. print() and str() call str; the REPL and repr() call repr.

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

    def __repr__(self):
        return f"Point({self.x!r}, {self.y!r})"

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

p = Point(1, 2)
print(p)          # (1, 2)          -- uses __str__
print(repr(p))    # Point(1, 2)     -- uses __repr__
[p]                # [Point(1, 2)]  -- containers repr() their elements

Rich Comparisons

Python has no default </>//>= for custom objects, and == defaults to identity (is) unless overridden. Each operator maps to its own dunder: eq, ne, lt, le, gt, ge — collectively the rich comparison methods.

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

    def __eq__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.cents == other.cents

    def __lt__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        return self.cents < other.cents

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

print(Money(100) == Money(100))   # True
print(Money(50) < Money(100))     # True
sorted([Money(300), Money(100)])  # [Money(100), Money(300)] -- sorted() only needs __lt__

Return NotImplemented (not False) when the other operand’s type is unsupported — Python then tries the reflected method on the other object before giving up with a TypeError.

functools.total_ordering

Defining eq plus just one of lt/le/gt/ge is enough for the @functools.total_ordering class decorator to fill in the rest:

from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor):
        self.major, self.minor = major, minor

    def __eq__(self, other):
        return (self.major, self.minor) == (other.major, other.minor)

    def __lt__(self, other):
        return (self.major, self.minor) < (other.major, other.minor)

v1, v2 = Version(1, 2), Version(1, 5)
print(v1 <= v2, v1 > v2)   # True False -- __le__ and __gt__ were synthesized

Container Protocol: len, getitem, setitem

Implementing the container-type special methods makes a class behave like a built-in sequence or mapping: len(obj) calls len, obj[key] calls getitem, obj[key] = value calls setitem, and slicing (obj[1:3]) passes a slice object as key.

class Grid:
    def __init__(self, width, height):
        self._data = [0] * (width * height)
        self.width = width

    def __len__(self):
        return len(self._data)

    def _index(self, row, col):
        return row * self.width + col

    def __getitem__(self, key):
        row, col = key
        return self._data[self._index(row, col)]

    def __setitem__(self, key, value):
        row, col = key
        self._data[self._index(row, col)] = value

g = Grid(3, 2)
g[0, 1] = 9
print(g[0, 1], len(g))   # 9 6

Slicing support just means checking whether key is a slice:

class Series:
    def __init__(self, values):
        self._values = list(values)

    def __len__(self):
        return len(self._values)

    def __getitem__(self, key):
        if isinstance(key, slice):
            return Series(self._values[key])   # a Series back, not a bare list
        return self._values[key]

s = Series([10, 20, 30, 40])
print(s[1])       # 20
print(s[1:3]._values)   # [20, 30]

iter/next for a Custom Iterable

A class with getitem is iterable by a legacy fallback, but the proper way to make a class a first-class iterable is the iterator protocol — iter returning an iterator and next raising StopIteration when exhausted. This is covered in full, including generators as the usual shortcut, in Iterators, Generators, and Comprehensions:

class Countdown:
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        self._current = self.start
        return self

    def __next__(self):
        if self._current <= 0:
            raise StopIteration
        self._current -= 1
        return self._current + 1

for n in Countdown(3):
    print(n)   # 3  2  1

call: Making Instances Callable

An object with call can be invoked with () like a function, which is how decorators, memoizers, and stateful callbacks are often implemented:

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor

double = Multiplier(2)
print(double(21))       # 42
print(callable(double))  # True

enter/exit: Writing a Custom Context Manager

The with statement is syntax over a protocol: on entry it calls enter (whose return value is bound by as), and on exit — normal or via exception — it calls exit with the exception info. Files and Context Managers covers this protocol in depth for the common case of open(); here it is implemented directly on a class:

class Timer:
    def __enter__(self):
        import time
        self._start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        import time
        self.elapsed = time.perf_counter() - self._start
        return False   # False/None propagates any exception; True would suppress it

with Timer() as t:
    total = sum(range(1_000_000))
print(f"took {t.elapsed:.4f}s")

Returning a truthy value from exit suppresses a raised exception; returning False/None (the common case) lets it propagate normally after cleanup runs.

Arithmetic Operators: add, radd, iadd

` dispatches to `__add__` on the left operand; if that returns `NotImplemented` (or the left operand's type doesn't define it), Python tries `__radd__` on the *right* operand -- this is what lets `5 + my_vector` work even though `int.__add__` knows nothing about `Vector`. `= tries the in-place iadd first and only falls back to add (then rebinding the name) if iadd is absent. Every other arithmetic operator follows the identical three-method pattern (sub/rsub, etc.) documented alongside add in the special method names reference.

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

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)

    def __radd__(self, other):
        # supports sum([...]) starting its accumulator at int 0
        if other == 0:
            return self
        return self.__add__(other)

    def __iadd__(self, other):
        self.x += other.x
        self.y += other.y
        return self   # __iadd__ must return the (possibly mutated) instance

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

v = Vector(1, 2)
print(v + Vector(3, 4))     # Vector(4, 6)   -- __add__
print(sum([Vector(1, 1), Vector(2, 2)]))   # Vector(3, 3)  -- __radd__ handles the initial 0
v += Vector(1, 1)
print(v)                    # Vector(2, 3)   -- __iadd__ mutated in place

If iadd is omitted, v += other simply becomes v = v.add(other), producing a new object instead of mutating — fine for immutable-style value types, but worth choosing deliberately.

See Also