Type something to search...
Python Dunder Methods: Making Your Classes Feel Built-In

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 writePython calls
repr(x)type(x).__repr__(x)
str(x), print(x)type(x).__str__(x)
f"{x:spec}"type(x).__format__(x, "spec")
x == yx.__eq__(y)
x < yx.__lt__(y)
hash(x)x.__hash__()
x + yx.__add__(y), then y.__radd__(x)
len(x)x.__len__()
x[i]x.__getitem__(i)
item in xx.__contains__(item)
for item in xx.__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() and str() 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__ powers len(), and since there's no __bool__, also truthiness. An empty playlist is falsy.
  • __getitem__ receives either an integer or a slice object. Handling slices explicitly lets mix[1:3] return a Playlist instead of a bare list. The @overload stubs are only for type checkers, so they know an integer gives a str and a slice gives a Playlist. Negative indices work because they're passed straight to the underlying list. For the slicing rules themselves, see slicing in Python.
  • __contains__ powers in. 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, powering for loops and everything that consumes iterables. Returning iter(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:

MethodPurpose
__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 NotImplemented from 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 a Money object 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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading