Dunder Methods Demystified: Making Your Python Classes Feel Built-In
Learn how Python's dunder methods work under the hood: write useful __repr__ and __eq__ implementations, overload operators safely with NotImplemented, support len() and iteration, and know when operator overloading is a bad idea.
When you write len(items), a + b, or if x in collection:, Python isn't doing anything magical — it's calling methods on your objects. len(items) becomes items.__len__(), a + b becomes a.__add__(b), and in checks __contains__. These double-underscore ("dunder") methods are the protocol layer that makes built-in types feel so pleasant to use, and the good news is that your own classes can join in. This post walks through the dunder methods that matter most in day-to-day code, in roughly the order you'll need them.
Start with __repr__ — always
If you implement only one dunder method on a class, make it __repr__. It's what you see in the REPL, in debugger variable panes, inside collections, and in logs — and the default is useless:
class Order:
def __init__(self, order_id, total):
self.order_id = order_id
self.total = total
>>> Order("A-1042", 99.90)
<__main__.Order object at 0x104f3e150>
The convention: __repr__ should be unambiguous and, ideally, look like the code that would recreate the object:
class Order:
def __init__(self, order_id, total):
self.order_id = order_id
self.total = total
def __repr__(self):
return f"Order(order_id={self.order_id!r}, total={self.total!r})"
>>> orders = [Order("A-1042", 99.90), Order("A-1043", 12.50)]
>>> orders
[Order(order_id='A-1042', total=99.9), Order(order_id='A-1043', total=12.5)]
The !r conversion calls repr() on each field, so strings keep their quotes. There's a companion method, __str__, meant for user-facing output (print() and str() use it). If you don't define __str__, Python falls back to __repr__ — which is exactly why __repr__ comes first. Define __str__ only when you need a second, prettier representation.
Equality: __eq__ and the __hash__ contract
By default, == on your objects compares identity — two orders with identical data are "not equal" unless they're the same object in memory. Fix it with __eq__:
class Order:
def __init__(self, order_id, total):
self.order_id = order_id
self.total = total
def __eq__(self, other):
if not isinstance(other, Order):
return NotImplemented
return (self.order_id, self.total) == (other.order_id, other.total)
Two details here are easy to get wrong. First, return NotImplemented (the object, not the exception NotImplementedError) when the other operand is a type you don't understand. That tells Python to try the reflected operation on the other object, and to fall back to identity comparison if that fails too — instead of crashing or silently returning False for cases another class could have handled.
Second, defining __eq__ sets __hash__ to None, making your objects unhashable — they can no longer go in sets or be dict keys. That's deliberate: equal objects must have equal hashes, and Python can't guess your hash from your equality. If your objects are immutable in practice, restore hashability yourself:
def __hash__(self):
return hash((self.order_id, self.total))
Hash the same tuple of fields you compare in __eq__, and only make objects hashable if those fields never change after construction — a mutated key silently disappears from its own dict bucket, which is a miserable bug to track down.
If this is starting to sound like boilerplate, that's because it is: @dataclass(frozen=True) generates __repr__, __eq__, and __hash__ for you, and is the right choice for plain data containers. Dunder methods are still worth understanding for classes with behavior, and for reading the code dataclasses generate on your behalf.
Ordering with __lt__ and total_ordering
Sorting only needs __lt__:
class Version:
def __init__(self, major, minor, patch):
self.parts = (major, minor, patch)
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self.parts < other.parts
>>> sorted([Version(3, 15, 0), Version(3, 9, 12), Version(3, 14, 6)])
But <=, >, and >= each have their own dunder, and writing four near-identical methods is error-prone. The standard-library shortcut is functools.total_ordering: define __eq__ and one ordering method, and the decorator derives the rest:
from functools import total_ordering
@total_ordering
class Version:
... # __eq__ and __lt__ as above; <=, >, >= now work too
Arithmetic operators, done responsibly
Operator overloading shines when your type genuinely behaves like a number or quantity. A money type is the classic example:
class Money:
def __init__(self, amount, currency):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if other.currency != self.currency:
raise ValueError(f"Cannot add {other.currency} to {self.currency}")
return Money(self.amount + other.amount, self.currency)
def __mul__(self, factor):
if not isinstance(factor, (int, float)):
return NotImplemented
return Money(self.amount * factor, self.currency)
def __rmul__(self, factor):
return self.__mul__(factor)
>>> Money(10, "EUR") + Money(5, "EUR")
Money(15, 'EUR')
>>> 3 * Money(10, "EUR")
Money(30, 'EUR')
Three practices to notice. Operators return new objects rather than mutating — users expect + to behave like it does on numbers and strings. The reflected method __rmul__ handles the case where your object is on the right side (3 * money): Python tries (3).__mul__(money) first, gets NotImplemented, then tries your __rmul__. And invalid operands return NotImplemented so Python can produce a proper TypeError naming both types.
The flip side: don't overload operators for operations with no obvious arithmetic meaning. user1 + user2 forces every reader to look up what you decided it means. If a competent colleague couldn't guess the behavior from the expression alone, use a named method instead.
Making a class behave like a container
The sequence protocol is where dunder methods pay off most. Implement __len__ and __getitem__ and your class supports indexing, slicing, iteration, in, reversed(), and unpacking — most of it for free:
class Playlist:
def __init__(self, name, tracks):
self.name = name
self._tracks = list(tracks)
def __repr__(self):
return f"Playlist({self.name!r}, {len(self)} tracks)"
def __len__(self):
return len(self._tracks)
def __getitem__(self, index):
result = self._tracks[index]
if isinstance(index, slice):
return Playlist(f"{self.name} (slice)", result)
return result
>>> pl = Playlist("Focus", ["Intro", "Deep Work", "Flow State", "Outro"])
>>> len(pl)
4
>>> pl[0]
'Intro'
>>> pl[1:3]
Playlist('Focus (slice)', 2 tracks)
>>> "Flow State" in pl # falls back to __getitem__ iteration
True
>>> for track in pl: # so does for-in
... print(track)
Iteration and in work here without __iter__ or __contains__ because Python falls back to calling __getitem__ with 0, 1, 2, … until IndexError. For large containers you'll still want to define them explicitly — __contains__ can use a set for O(1) membership instead of a linear scan, and __iter__ (usually a generator) is clearer and faster than the index fallback. Note also how __getitem__ checks for slice: delegating to the underlying list's indexing handles both cases, and returning a new Playlist for slices keeps the type consistent, the way list slicing returns a list.
Truthiness and callability
Two small protocols round out the everyday set. __bool__ controls what happens in if obj: — without it, Python uses __len__ (empty means falsy), and if neither exists every instance is truthy. Define it when emptiness isn't the right notion of "false":
class QueryResult:
def __init__(self, rows, error=None):
self.rows = rows
self.error = error
def __bool__(self):
return self.error is None
__call__ lets instances be invoked like functions — useful for objects that are conceptually "a configured operation", like a rate limiter or a validator built from rules:
class Throttle:
def __init__(self, per_second):
self.per_second = per_second
self._last = 0.0
def __call__(self, func, *args):
import time
wait = self._last + 1 / self.per_second - time.monotonic()
if wait > 0:
time.sleep(wait)
self._last = time.monotonic()
return func(*args)
throttle = Throttle(per_second=2)
throttle(send_request, payload) # instance used like a function
Common pitfalls
A few mistakes come up repeatedly. Raising NotImplementedError where NotImplemented should be returned breaks Python's operand-reflection machinery. Defining __eq__ and forgetting that it kills hashability leads to a confusing TypeError: unhashable type far from the class definition. Calling dunder methods directly (obj.__len__()) instead of using the built-in (len(obj)) skips type checks and looks unidiomatic. And over-implementing is a smell of its own — if a class defines fifteen dunders, ask whether it should really be a dataclass, a plain function, or a subclass of collections.abc.Sequence, which fills in the derived protocol methods once you supply __len__ and __getitem__.
Wrap-up and next steps
Dunder methods are Python's answer to "how do I make my types feel native?" Start every class with a decent __repr__, add __eq__ (plus __hash__ for immutable types) when value equality matters, reach for total_ordering instead of hand-writing four comparison methods, and implement the container protocol when your class wraps a collection. As a next step, take a class from your own codebase and check what repr() of it looks like right now — if the answer is a hex address, you know where to start. From there, the collections.abc module and the data model chapter of the Python reference are the definitive map of every protocol your objects can opt into.