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:
| Expression | Method | Expression | Method |
|---|---|---|---|
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
Falseinstead ofNotImplementedfor a foreign type in__eq__/__lt__/__add__. __eq__without__hash__on a class meant to be a key.- Mutating
selfin__add__; that is__iadd__'s job. __str__without__repr__— containers still show the ugly default.- Forgetting
__rmul__, so3 * mfails whilem * 3works. - 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 userepr.__eq__+__hash__over the same fields;__lt__+@total_orderingfor the rest; returnNotImplementedfor 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 returnsself.
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 2A 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 outroIn this module: Classes and objects
- Defining classes — __init__, self, attributes and methods
- Dunder methods — making a class behave like a built-in (this lesson)
- Properties and encapsulation — the underscore, @property and __slots__
- Class methods and static methods
- Dataclasses — classes that are mostly data
- Designing a class — invariants, interfaces and a worked example
- Checkpoint — Classes and objects
← Defining classes — __init__, self, attributes and methods · Properties and encapsulation — the underscore, @property and __slots__ →