Python dunder methods, explained
The reason len(x), x + y, x[0], for i in x, and print(x) all "just work" on your
own classes is Python's data model: a set of specially named dunder methods
(double-underscore, like __len__) that the interpreter calls behind the scenes. Master
these and your objects integrate seamlessly with the language's syntax. This guide covers
the dunders interviewers ask about most.
What dunder methods are
Dunder ("double underscore") methods — also called magic methods — are hooks Python
calls for built-in operations. You rarely call them directly; you trigger them through
syntax. len(x) calls x.__len__(), x + y calls x.__add__(y), x[k] calls
x.__getitem__(k).
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other): # called by the + operator
return Vector(self.x + other.x, self.y + other.y)
Vector(1, 2) + Vector(3, 4) # Python calls __add__ for you
The idea is protocols, not inheritance: anything that implements the right dunders behaves like a built-in, no base class required.
repr vs str
Both produce a string, but for different audiences. __repr__ is the unambiguous,
developer-facing representation (ideally something that could recreate the object);
__str__ is the readable, user-facing one. str()/print use __str__ and fall back
to __repr__ if it's missing.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})" # for developers / the REPL
def __str__(self):
return f"({self.x}, {self.y})" # for end users
p = Point(1, 2)
repr(p) # 'Point(x=1, y=2)'
str(p) # '(1, 2)'
Always define __repr__ — it's what you see in the debugger, in logs, and inside
containers. __str__ is optional and only worth adding when the user-facing form differs.
eq and hash go together
__eq__ defines value equality (==); __hash__ lets instances live in sets and act as
dict keys. They must be consistent: equal objects must have equal hashes. Defining
__eq__ alone makes the class unhashable (Python sets __hash__ to None).
class Money:
def __init__(self, cents):
self.cents = cents
def __eq__(self, other):
return self.cents == other.cents
def __hash__(self):
return hash(self.cents) # consistent with __eq__
{Money(100), Money(100)} # one element — treated as equal
Hash only on fields that never change after creation; otherwise a mutated key gets lost in its hash bucket.
Operator overloading
Arithmetic and comparison operators map to dunders: + → __add__, * → __mul__, < →
__lt__, and so on. Implementing them lets your objects participate in expressions
naturally.
class Money:
def __init__(self, cents): self.cents = cents
def __add__(self, other): return Money(self.cents + other.cents)
def __lt__(self, other): return self.cents < other.cents
def __repr__(self): return f"${self.cents/100:.2f}"
Money(150) + Money(50) # $2.00
Money(150) < Money(200) # True
For comparisons, functools.total_ordering fills in the rest from just __eq__ and one
ordering method, saving boilerplate.
The sequence/container protocol
Implement __len__ and __getitem__ and your object behaves like a sequence — it supports
len(), indexing, slicing, and iteration (Python falls back to __getitem__ with
0, 1, 2… if there's no __iter__), plus in.
class Deck:
def __init__(self, cards): self._cards = cards
def __len__(self): return len(self._cards)
def __getitem__(self, i): return self._cards[i]
deck = Deck(["A", "K", "Q"])
len(deck) # 3
deck[0] # 'A'
for c in deck: # iterable for free
print(c)
This is duck typing at its best: implement the protocol and the object is a sequence,
without subclassing list.
call: make instances callable
Defining __call__ lets you call an instance like a function — useful for stateful
"function objects," decorators, and configurable callbacks.
class Multiplier:
def __init__(self, factor): self.factor = factor
def __call__(self, x): return x * self.factor
double = Multiplier(2)
double(10) # 20 — the instance behaves like a function
Context-manager dunders
__enter__ and __exit__ make an object usable in a with block, guaranteeing cleanup.
That's the same protocol that powers open() — covered in depth in the context-managers
guide.
class Timer:
def __enter__(self): import time; self.t = time.perf_counter(); return self
def __exit__(self, *exc): print(f"{time.perf_counter() - self.t:.4f}s")
Recap
Dunder methods are the hooks Python calls for built-in syntax — they're how user
classes plug into len(), operators, indexing, iteration, print, and with. Always
give a class a __repr__; pair __eq__ with a consistent __hash__ (and hash only on
immutable fields); overload operators with __add__/__lt__/etc.; implement
__len__+__getitem__ for sequence behaviour; and use __call__ for callable instances.
Think protocols, not inheritance — implement the right dunders and your objects feel
native.