Python Magic Methods: Dunders and Operator Overloading

Python operators and built-ins call dunder methods on your class. repr vs str, equality and ordering, len and iteration, arithmetic and NotImplemented.

  • Course: Python study plan
  • Module: Classes and objects
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: CPython 3.11

What are dunder methods in Python?

Dunder (double-underscore) methods, also called magic methods, are the special methods Python calls to carry out operators and built-in functions: a + b calls a.__add__(b), len(x) calls x.__len__() and x in c calls c.__contains__(x). Together they form Python's data model; define one in a class and its instances support the matching syntax, like built-in types.

Lesson

Every operator and built-in function in Python is a call to a dunder (double-underscore) method: a + b is a.__add__(b), len(x) is x.__len__(), x in c is c.__contains__(x), print(x) is x.__str__(). Define the method and your class gets the operator; this is the data model, and it is why a user-defined type can be as pleasant to use as a list or an int. This lesson covers the dunders that matter first — representation, equality and ordering, size and containment, iteration, arithmetic, truth and calling — with the conventions (NotImplemented, total_ordering, reflected operators) that make them behave correctly.

Representation

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

    def __repr__(self):                     # unambiguous, for developers
        return f"Money({self.cents}, {self.currency!r})"

    def __str__(self):                      # readable, for users
        return f"{self.cents // 100}.{self.cents % 100:02d} {self.currency}"

m = Money(1250)
repr(m)        # "Money(1250, 'GBP')"
str(m)         # '12.50 GBP'
print(m)       # 12.50 GBP       — print uses __str__
print([m])     # [Money(1250, 'GBP')] — containers use __repr__
f"{m}"         # str;  f"{m!r}" for repr

If only __repr__ is defined, str falls back to it, so __repr__ alone is enough for most classes; add __str__ when the user-facing text should differ. __format__(self, spec) lets f"{m:>12}" work with a custom spec.

Equality and hashing

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

    def __hash__(self):
        return hash((self.cents, self.currency))

Without __eq__, == is identity. Returning NotImplemented (not False) for a foreign type lets Python try other.__eq__(self) and then fall back to identity, so Money(1) == 5 is False without an error. __ne__ is derived automatically. Defining __eq__ makes the class unhashable unless __hash__ is also defined (Module 7 lesson 4) — define it over the same fields, or leave the class unhashable deliberately if it is mutable.

Ordering

from functools import total_ordering

@total_ordering
class Money:
    ...
    def __lt__(self, other):
        if not isinstance(other, Money) or other.currency != self.currency:
            return NotImplemented
        return self.cents < other.cents

__lt__ alone makes sorted, min and max work (they only use <). @total_ordering derives __le__, __gt__, __ge__ from __lt__ plus __eq__, so you write two and get six. The comparison dunders should return NotImplemented for types they cannot order; Python then raises TypeError with a clear message.

Size, containment, indexing, iteration

class Playlist:
    def __init__(self, tracks):
        self._tracks = list(tracks)

    def __len__(self):                  # len(p)
        return len(self._tracks)

    def __contains__(self, track):      # track in p
        return track in self._tracks

    def __getitem__(self, i):           # p[i], p[1:3]; iteration falls back on this
        return self._tracks[i]

    def __iter__(self):                 # for t in p — preferred over the __getitem__ fallback
        return iter(self._tracks)

    def __bool__(self):                 # if p:   — without it, __len__ decides truthiness
        return bool(self._tracks)

__len__ makes len() work and, absent __bool__, makes an empty object falsy. __getitem__ receiving a slice object is what makes p[1:3] work — delegating to the inner list handles it. __iter__ should return an iterator (iter(self._tracks), or a generator with yield, Module 11); __reversed__, __setitem__ and __delitem__ complete the mutable-sequence set.

Arithmetic

    def __add__(self, other):                       # self + other
        if not isinstance(other, Money) or other.currency != self.currency:
            return NotImplemented
        return Money(self.cents + other.cents, self.currency)

    def __mul__(self, factor):                      # self * 3
        if not isinstance(factor, int):
            return NotImplemented
        return Money(self.cents * factor, self.currency)

    def __rmul__(self, factor):                     # 3 * self — the reflected form
        return self * factor

    def __neg__(self):                              # -self
        return Money(-self.cents, self.currency)

For a + b Python calls a.__add__(b); if that returns NotImplemented it tries b.__radd__(a). That is how 3 * money works: int.__mul__ does not know Money, so Money.__rmul__ is tried. Arithmetic dunders should return a new object and leave the operands alone; __iadd__ (for +=) may mutate in place and must return self. sum(monies, Money(0)) works once __add__ exists — the start value matters because sum begins from 0.

Calling and the rest

__call__(self, *args) makes an instance callable (counter()), which is how a class becomes a function with state. __enter__/__exit__ make it a context manager (Module 10). __getattr__ intercepts missing attributes (Module 16). A summary of the ones to know:

ExpressionMethodExpressionMethod
repr(x), str(x)__repr__, __str__x + y, x * y__add__, __mul__ (+ __r…__)
x == y, x < y__eq__, __lt__-x, abs(x)__neg__, __abs__
hash(x)__hash__x += y__iadd__
len(x), bool(x)__len__, __bool__x()__call__
x[i], x[i] = v__getitem__, __setitem__with x:__enter__, __exit__
for e in x__iter__int(x), float(x)__int__, __float__
e in x__contains__x.missing__getattr__

Pitfalls

  • Returning False instead of NotImplemented for a foreign type in __eq__/__lt__/__add__.
  • __eq__ without __hash__ on a class meant to be a key.
  • Mutating self in __add__; that is __iadd__'s job.
  • __str__ without __repr__ — containers still show the ugly default.
  • Forgetting __rmul__, so 3 * m fails while m * 3 works.
  • A __len__ that can be zero on an object that should be truthy — add __bool__.

Key takeaways

  • Operators and built-ins dispatch to dunders; define them and the class gains the syntax.
  • __repr__ looks like the constructor; __str__ is the readable form; containers use repr.
  • __eq__ + __hash__ over the same fields; __lt__ + @total_ordering for the rest; return NotImplemented for foreign types.
  • __len__, __contains__, __getitem__, __iter__, __bool__ make a collection type; __getitem__ alone gives iteration.
  • Binary dunders return new objects; __r…__ handles the reflected call; __iadd__ mutates and returns self.

Common questions

What is the difference between __str__ and __repr__ in Python?

__repr__ is the unambiguous text for developers, ideally looking like the constructor call; __str__ is the readable text for users. print and str() use __str__ and fall back to __repr__, while containers always show their elements' __repr__ — so define __repr__ first.

Why return NotImplemented instead of False in __eq__?

NotImplemented tells Python that this method cannot compare the two types, so it tries the other operand's method and finally falls back to identity. Returning False ends the comparison early and blocks that reflected call; with NotImplemented, Money(1) == 5 is still simply False.

How do I overload operators in Python?

Define the matching dunder: __add__ for +, __mul__ for *, __neg__ for unary minus, __lt__ for <. Return a new object and leave the operands unchanged, return NotImplemented for types you cannot handle, and add a reflected method such as __rmul__ so that 3 * x works as well as x * 3.

What does functools.total_ordering do?

@total_ordering fills in a class's missing comparison methods: define __eq__ and one ordering method such as __lt__, and it derives __le__, __gt__ and __ge__. sorted, min and max need only __lt__.

How does Python decide whether an object is truthy?

It calls __bool__ if the class defines it; otherwise it calls __len__ and treats zero as false; with neither, every instance is truthy. Add __bool__ when an object whose length can be zero should still count as true.

Exercises

Money with operators

Implement Money holding integer cents with __add__, __mul__ and __rmul__ (by an int), __eq__, __lt__ (decorated with functools.total_ordering), __hash__ and __str__ printing 12.50. Return NotImplemented for foreign types. Read n amounts as text and print: their total via sum(amounts, Money(0)), the largest, the amounts sorted, 3 * first, and the number of distinct amounts via a set.

Input: n, then n amounts with two decimals. Output: total <x>, max <x>, sorted <x ...>, triple <x>, distinct <k>.

3
12.50
0.75
12.50

prints

total 25.75
max 12.50
sorted 0.75 12.50 12.50
triple 37.50
distinct 2

A playlist that is a collection

Implement Playlist over an internal list of titles with __len__, __contains__, __getitem__ (delegating to the list, so negative indexes and slices work), __iter__ and __bool__. Run commands: add title, len, has title, at i (no such track on IndexError), list (all titles space-separated, or empty when the playlist is falsy).

Input: commands. Output: one line per query.

list
add intro
add outro
len
has intro
at -1
at 5
list

prints

empty
2
True
outro
no such track
intro outro

In this module: Classes and objects

← Defining classes — __init__, self, attributes and methods · Properties and encapsulation — the underscore, @property and __slots__ →