
Python Dunder Methods: Making Your Classes Feel Built-In
Why does len(my_list) work, but len(my_object) raises a TypeError? Why can you add two numbers with + and compare two strings with <, but your own classes do neither? The answer is that built-in types implement a set of special methods, and Python's syntax and built-in functions call those methods behind the scenes. len(x) calls x.__len__(). a + b calls a.__add__(b). print(x) calls x.__str__().
These methods have names that start and end with double underscores, which is why they're called dunder methods (or magic methods, or special methods). Your classes can define them too, and when they do, they plug into the same machinery as int, str, and list. The result is classes that feel native: they print readably, compare correctly, work in sets and as dict keys, support operators, and play well with sum(), sorted(), in, and for.
This post walks through the dunder methods you'll actually use, grouped by what they enable, using a Money class and a Playlist container as running examples. It assumes you're comfortable with classes and objects in Python.
How Dunder Methods Work
You almost never call a dunder method directly. You use the syntax or built-in, and Python calls the method for you:
| You write | Python calls |
|---|---|
repr(x) | type(x).__repr__(x) |
str(x), print(x) | type(x).__str__(x) |
f"{x:spec}" | type(x).__format__(x, "spec") |
x == y | x.__eq__(y) |
x < y | x.__lt__(y) |
hash(x) | x.__hash__() |
x + y | x.__add__(y), then y.__radd__(x) |
len(x) | x.__len__() |
x[i] | x.__getitem__(i) |
item in x | x.__contains__(item) |
for item in x | x.__iter__() |
bool(x), if x: | x.__bool__(), then x.__len__() |
x() | x.__call__() |
with x: | x.__enter__(), x.__exit__(...) |
One detail matters: Python looks these methods up on the type, not the instance. Assigning obj.__len__ = lambda: 5 to a single object does nothing for len(obj). Define dunder methods in the class body.
And a word of caution: only define the dunder names Python documents. Inventing your own __like_this__ names is reserved for the language, and a future Python version might give the name a meaning.
The Money Class
Here's the class we'll build up. It represents an amount in a currency, using Decimal to avoid floating-point rounding errors:
# money.py
from decimal import Decimal
from functools import total_ordering
from typing import Self
@total_ordering
class Money:
def __init__(self, amount: Decimal | int | str, currency: str = "USD") -> None:
self.amount = Decimal(amount)
self.currency = currency
# --- representation -------------------------------------------------
def __repr__(self) -> str:
return f"Money({str(self.amount)!r}, {self.currency!r})"
def __str__(self) -> str:
return f"{self.amount:,.2f} {self.currency}"
def __format__(self, spec: str) -> str:
if not spec:
return str(self)
return f"{format(self.amount, spec)} {self.currency}"
# --- comparison and hashing -----------------------------------------
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
def __lt__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
self._check_currency(other)
return self.amount < other.amount
def __hash__(self) -> int:
return hash((self.amount, self.currency))
# --- arithmetic ------------------------------------------------------
def __add__(self, other: object) -> Self:
if not isinstance(other, Money):
return NotImplemented
self._check_currency(other)
return type(self)(self.amount + other.amount, self.currency)
def __radd__(self, other: object) -> Self:
if other == 0: # lets sum() start from its default of 0
return self
return self.__add__(other)
def __mul__(self, factor: object) -> Self:
if not isinstance(factor, (int, Decimal)):
return NotImplemented
return type(self)(self.amount * factor, self.currency)
__rmul__ = __mul__
def __neg__(self) -> Self:
return type(self)(-self.amount, self.currency)
def __bool__(self) -> bool:
return self.amount != 0
def _check_currency(self, other: "Money") -> None:
if self.currency != other.currency:
raise ValueError(f"cannot combine {self.currency} and {other.currency}")
That's a lot at once, so let's go group by group.
Representation: __repr__, __str__, and __format__
price = Money("19.99")
print(repr(price))
print(price)
print(f"Total: {price}")
print(f"Total: {price:>10.2f}")
print([price])
Money('19.99', 'USD')
19.99 USD
Total: 19.99 USD
Total: 19.99 USD
[Money('19.99', 'USD')]
The two main methods have different audiences:
__repr__is for developers. It's what you see in the REPL, in debuggers, in logs, and inside containers (notice the list printed the repr). The convention is to make it look like the code that would recreate the object, when that's practical.__str__is for end users.print()andstr()use it, as do f-strings with no format spec.
If you only define one, define __repr__. When __str__ is missing, Python falls back to __repr__. The reverse isn't true: a class with only __str__ still shows <__main__.Money object at 0x...> inside lists and in the debugger.
__format__ receives whatever comes after the colon in an f-string or format() call. Here, Money forwards the spec to the underlying Decimal and appends the currency, so {price:>10.2f} right-aligns the number. If you don't define __format__, any non-empty spec raises TypeError. For more on what format specs can do, see f-strings in Python.
Equality and Hashing: __eq__ and __hash__
By default, == compares identity: two objects are equal only if they're the same object. For a value type like money, that's wrong. __eq__ fixes it:
print(price == Money("19.99"), price == Money("19.99", "EUR"), price == 19.99)
True False False
Return NotImplemented, Don't Raise
Look at how __eq__ handles a non-Money argument: it returns the special singleton NotImplemented. That's not the same as raising NotImplementedError. It tells Python "I don't know how to compare with this type, ask the other object". Python then tries the reflected operation on the other operand, and if that also returns NotImplemented, it falls back to a sensible default: identity for == (hence False above), or a TypeError for operators like < and +.
Returning NotImplemented keeps your class from breaking comparisons and arithmetic with types it doesn't know about, and lets other types interoperate with yours if they choose to.
__eq__ and __hash__ Go Together
Here's a rule that surprises people: if you define __eq__ without __hash__, Python sets __hash__ to None, and your objects become unhashable.
class Point:
def __init__(self, x: int) -> None:
self.x = x
def __eq__(self, other: object) -> bool:
return isinstance(other, Point) and self.x == other.x
{Point(1)} # TypeError: unhashable type: 'Point'
This is deliberate. Sets and dicts rely on the rule that equal objects must have equal hashes. The default hash is based on identity, which would break that rule as soon as equality is based on value. So Python removes it and makes you decide.
The usual implementation hashes a tuple of the same fields that __eq__ compares:
def __hash__(self) -> int:
return hash((self.amount, self.currency))
Now Money works in sets and as dict keys, and equal values collapse together:
print(len({Money(5), Money("5.00"), Money(5, "EUR")}))
2
Money(5) and Money("5.00") are equal (because Decimal("5") == Decimal("5.00")) and hash the same, so the set keeps one of them.
Only make an object hashable if the fields involved in the hash won't change while it's in a set or used as a key. If they can change, leave __hash__ as None. Mutating a hashed field of an object that's already inside a set makes it impossible to find again.
Ordering: __lt__ and functools.total_ordering
There are six rich comparison methods: __lt__, __le__, __gt__, __ge__, __eq__, and __ne__. You rarely need to write them all. Python derives != from __eq__ automatically, and the @functools.total_ordering decorator fills in the rest from __eq__ plus one ordering method:
print(price < Money(25), price >= Money(25), max(Money(5), Money(12), Money(8)))
True False 12.00 USD
Money only defines __lt__, yet >= works, and so do max(), min(), and sorted(), which all use comparison operators under the hood. Comparing different currencies raises a ValueError, while comparing with an unrelated type gives the standard error:
TypeError: '<' not supported between instances of 'Money' and 'int'
The derived methods from total_ordering are a little slower than hand-written ones. That's irrelevant for most code; if you're sorting millions of objects, consider sorting with a key= function instead.
Arithmetic: __add__, __radd__, __mul__, and Friends
Operators map to methods: + to __add__, - to __sub__, * to __mul__, / to __truediv__, // to __floordiv__, % to __mod__, ** to __pow__, @ to __matmul__, and the bitwise operators to __and__, __or__, __xor__, __lshift__, and __rshift__. Unary minus is __neg__, unary plus __pos__, and abs() calls __abs__.
print(price + Money("0.01"))
print(price * 3, 3 * price)
print(-price)
20.00 USD
59.97 USD 59.97 USD
-19.99 USD
Each arithmetic method returns a new object rather than modifying self. Value types should behave like numbers: a + b never changes a. Using type(self)(...) instead of Money(...) means subclasses get instances of their own type back, and Self in the return annotation expresses that.
Reflected Operators
price * 3 calls Money.__mul__(price, 3). But 3 * price first calls int.__mul__(3, price), which has no idea what Money is and returns NotImplemented. Python then tries the reflected method on the right operand: Money.__rmul__(price, 3). Every binary operator has an __r*__ twin for exactly this case.
Multiplication is commutative here, so __rmul__ = __mul__ simply reuses the same function.
Making sum() Work
sum() starts from 0 and adds each item, so sum([Money(10), Money(20)]) begins with 0 + Money(10). That calls int.__add__, which returns NotImplemented, then Money.__radd__(Money(10), 0). Treating 0 as the identity in __radd__ makes sum() work without a custom start value:
print(sum([Money(10), Money(20), Money("2.50")]))
32.50 USD
Adding a plain number, on the other hand, stays an error, because __add__ returns NotImplemented and so does int's side:
TypeError: unsupported operand type(s) for +: 'Money' and 'int'
In-Place Operators
total += price looks for __iadd__ first. If it's not defined, Python falls back to total = total + price, which is exactly right for an immutable value type. Define __iadd__ only for mutable objects that should change in place, the way list.__iadd__ extends the list.
Truthiness: __bool__ and __len__
if x: calls x.__bool__(). If that isn't defined, Python uses __len__() and treats zero length as false. If neither exists, every object is truthy.
Money defines __bool__ so that zero money is falsy, just like the number zero:
print(bool(Money(0)), bool(price))
False True
Keep __bool__ consistent with what "empty" or "zero" means for your type, and don't make it do anything expensive or surprising. Understanding Python's truthiness covers the rules in detail.
Container Methods: __len__, __getitem__, __contains__, __iter__
The second family of dunder methods makes your objects behave like collections. Here's a playlist that wraps a list:
# playlist.py
from collections.abc import Iterator
from typing import overload
class Playlist:
def __init__(self, name: str, songs: list[str] | None = None) -> None:
self.name = name
self._songs = list(songs or [])
def __repr__(self) -> str:
return f"Playlist({self.name!r}, {self._songs!r})"
def __len__(self) -> int:
return len(self._songs)
@overload
def __getitem__(self, index: int) -> str: ...
@overload
def __getitem__(self, index: slice) -> "Playlist": ...
def __getitem__(self, index: int | slice) -> "str | Playlist":
if isinstance(index, slice):
return Playlist(f"{self.name} (excerpt)", self._songs[index])
return self._songs[index]
def __contains__(self, song: object) -> bool:
return song in self._songs
def __iter__(self) -> Iterator[str]:
return iter(self._songs)
mix = Playlist("Focus", ["Weightless", "Clair de Lune", "Experience", "Nuvole Bianche"])
print(len(mix), bool(mix), bool(Playlist("Empty")))
print(mix[0], mix[-1])
print(mix[1:3])
print("Experience" in mix)
for number, song in enumerate(mix, 1):
print(number, song)
print(sorted(mix))
print(list(reversed(mix)))
4 True False
Weightless Nuvole Bianche
Playlist('Focus (excerpt)', ['Clair de Lune', 'Experience'])
True
1 Weightless
2 Clair de Lune
3 Experience
4 Nuvole Bianche
['Clair de Lune', 'Experience', 'Nuvole Bianche', 'Weightless']
['Nuvole Bianche', 'Experience', 'Clair de Lune', 'Weightless']
Four short methods, and the playlist works with len(), indexing, negative indexing, slicing, in, for loops, enumerate(), sorted(), and reversed():
__len__powerslen(), and since there's no__bool__, also truthiness. An empty playlist is falsy.__getitem__receives either an integer or asliceobject. Handling slices explicitly letsmix[1:3]return aPlaylistinstead of a bare list. The@overloadstubs are only for type checkers, so they know an integer gives astrand a slice gives aPlaylist. Negative indices work because they're passed straight to the underlying list. For the slicing rules themselves, see slicing in Python.__contains__powersin. Without it, Python falls back to iterating and comparing each item, which works but can be slower for types with a faster lookup.__iter__returns an iterator, poweringforloops and everything that consumes iterables. Returningiter(self._songs)delegates to the list. The iterator protocol itself (__iter__and__next__) is covered in iterators and the iterator protocol.
reversed() worked even though there's no __reversed__. With __len__ and integer __getitem__, Python can walk the sequence backwards on its own.
To make a mutable container, add __setitem__ and __delitem__. If you want the full list or dict interface without writing every method, inherit from collections.abc.Sequence, MutableSequence, or Mapping: implement a few abstract methods, and the base class provides the rest, including index(), count(), and __contains__.
Callable Objects: __call__
Defining __call__ lets instances be called like functions. That's useful for objects that are mostly "a function with some state":
class RateLimiter:
def __init__(self, max_calls: int) -> None:
self.max_calls = max_calls
self.calls = 0
def __call__(self) -> bool:
self.calls += 1
return self.calls <= self.max_calls
allow = RateLimiter(max_calls=2)
print([allow() for _ in range(4)], callable(allow))
[True, True, False, False] True
Anything that expects a function, like a callback parameter or the key= argument of sorted(), will accept a callable object. Often a closure does the same job with less code; closures in Python compares the two.
Context Managers: __enter__ and __exit__
The with statement calls __enter__ at the start of the block and __exit__ at the end, even if an exception was raised. Here's a minimal timer:
import time
from types import TracebackType
from typing import Self
class Timer:
def __enter__(self) -> Self:
self.start = time.perf_counter()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.elapsed = time.perf_counter() - self.start
with Timer() as t:
sum(range(1_000_000))
print(f"took {t.elapsed:.3f}s")
took 0.013s
Whatever __enter__ returns is bound to the name after as. __exit__ receives the exception details (all None if the block finished normally); returning a true value would suppress the exception, which you should almost never do. There's much more to this protocol, including contextlib shortcuts, in context managers in Python.
Other Dunder Methods Worth Knowing
You'll meet these less often, but it's good to know they exist:
| Method | Purpose |
|---|---|
__new__ | Creates the instance before __init__; used for immutable subclasses and singletons |
__init_subclass__ | Runs when a class is subclassed; handy for plugin registries |
__getattr__ | Called only when normal attribute lookup fails |
__setattr__, __delattr__ | Intercept every attribute assignment or deletion |
__get__, __set__ | The descriptor protocol behind properties and methods |
__class_getitem__ | Supports MyClass[int] generic syntax |
__aenter__, __aexit__ | Async context managers (async with) |
__aiter__, __anext__ | Async iteration (async for) |
__index__ | Lets an object be used as an integer index or in bin()/hex() |
__copy__, __deepcopy__ | Customize the copy module |
The full list is in the data model chapter of the language reference, which is the authoritative source for all of these.
When to Let a Dataclass Do It
Writing __init__, __repr__, __eq__, __hash__, and the ordering methods by hand for every value class gets repetitive. The dataclasses module generates them from field declarations, and @dataclass(frozen=True, order=True) gives you most of what Money does in a few lines. Hand-written dunder methods are still the right choice when you need custom behavior, like currency checks in comparisons or the sum()-friendly __radd__. See dataclasses in Python for how far generated methods can take you.
Guidelines
- Always define
__repr__. It costs a few lines and makes every debugging session easier. - Return
NotImplementedfrom comparison and arithmetic methods when the other operand is a type you don't handle. - Define
__hash__whenever you define__eq__, or consciously leave the object unhashable if it's mutable. - Return new objects from arithmetic on value types; save in-place operators for mutable containers.
- Follow the built-ins' expectations.
__len__must return a non-negative int,__bool__must return a bool, and__eq__should be symmetric. Surprising behavior behind familiar syntax is worse than no support at all. - Don't overload operators for unrelated meanings.
+on aMoneyobject adds money. Using+to mean "send an email" makes code unreadable.
Conclusion
Dunder methods are how Python's syntax and built-ins talk to your objects. __repr__ and __str__ control how they print, __eq__ and __hash__ decide equality and set membership, __lt__ with total_ordering enables sorting, __add__ and its reflected twin make operators work in both directions, and __len__, __getitem__, __contains__, and __iter__ turn a class into a proper collection. Implement the ones that make sense for your type, return NotImplemented for operands you don't understand, and your classes will feel as natural to use as anything built into the language.


