Dunder methods
__repr__, __eq__, __hash__, __len__, __iter__, __call__ — the special methods that make your classes behave like Python's built-ins.
What you'll learn
- The dunder methods worth knowing — __repr__, __str__, __eq__, __hash__
- __len__, __iter__, __getitem__ for container-like classes
- __enter__/__exit__ for context managers, __call__ for callables
- Building a Money value object that works in sets and dicts
Before you start
The so-called “magic” methods are really just an interface. They are called dunder methods because their names carry double underscores on each side — __len__, __eq__, __iter__ — and they are how Python’s operators and built-in functions hook into your classes. len(x) is really x.__len__(); x == y is x.__eq__(y); for item in x: is x.__iter__(). Learn the protocol and your own classes can behave like lists, sets, numbers, or callables — first-class citizens of the language rather than awkward outsiders.
repr — every class deserves one
If you write a single dunder on a class, make it __repr__. It controls what you see when you print the object or inspect it in a debugger, and the default is genuinely useless:
class UserBad:
def __init__(self, id, name):
self.id = id
self.name = name
class UserGood:
def __init__(self, id, name):
self.id = id
self.name = name
def __repr__(self):
return f"User(id={self.id!r}, name={self.name!r})"
print(UserBad(1, "Aarav")) # the default — useless
print(UserGood(1, "Aarav")) # a custom repr — helpful
print([UserGood(1, "Aarav"), UserGood(2, "Priya")])
<__main__.UserBad object at 0x...>
User(id=1, name='Aarav')
[User(id=1, name='Aarav'), User(id=2, name='Priya')]
The 0x... in the first line is a memory address that changes every run — it tells you nothing about which user this is. Contrast User(id=1, name='Aarav'): anyone scanning a log or stepping through a debugger reads it instantly. The convention is that __repr__ should look like the constructor call that would recreate the object.
A close cousin is __str__. repr(obj) (developer-facing, unambiguous) calls __repr__; str(obj) (user-facing) calls __str__, but falls back to __repr__ if you have not defined it. For most internal classes, a good __repr__ alone is plenty; add __str__ only when the friendly form should differ from the debug form.
eq and hash — the inseparable pair
By default, two distinct objects are never equal even when every field matches, because Python compares identity (memory address). For value objects — money, points, IDs — that is almost always wrong:
class PointBad:
def __init__(self, x, y):
self.x, self.y = x, y
print("equal?", PointBad(1, 2) == PointBad(1, 2)) # same fields...
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point({self.x}, {self.y})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
a, b, c = Point(1, 2), Point(1, 2), Point(3, 4)
print("equal?", a == b)
print("set size:", len({a, b, c})) # a and b dedup -> 2 distinct
equal? False
equal? True
set size: 2
Two rules make this safe. First, if __eq__ compares the tuple (self.x, self.y), then __hash__ must hash the same tuple — equal objects are required to share a hash, or they break set and dict. (That is why len({a, b, c}) is 2: a and b are equal and hash alike, so the set folds them together.) Second, return NotImplemented when compared against an unrelated type, which lets Python try the other operand’s __eq__ instead of forcing a wrong answer.
A real value object — Money
Money is the textbook case: equality should compare amount and currency, the hash should let it live in a set, the repr should read clearly, and addition across currencies should refuse rather than lie. Built on Decimal, not floats:
from decimal import Decimal
class Money:
def __init__(self, amount, currency):
self.amount = Decimal(str(amount)) # Decimal — exact money arithmetic
self.currency = currency.upper()
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __hash__(self):
return hash((self.amount, self.currency))
def __add__(self, other):
if not isinstance(other, Money) or self.currency != other.currency:
raise ValueError("can't add different currencies (need an exchange rate)")
return Money(self.amount + other.amount, self.currency)
a = Money("9.99", "USD")
b = Money("9.99", "USD")
c = Money("9.99", "EUR")
print(a == b) # same amount and currency
print(a == c) # different currency
print("set size:", len({a, b, c}))
print(a + b)
prices = {Money("10", "USD"): "small", Money("25", "USD"): "large"}
print(prices[Money("25", "USD")]) # Money works as a dict key
True
False
set size: 2
Money(19.98, 'USD')
large
So the prediction resolves to 2: a and b are equal and hash alike, while c differs by currency. And because __eq__/__hash__ are consistent, a freshly built Money("25", "USD") looks up the right value in prices — the object is a true value, identity forgotten.
Container-like classes
If a class wraps a collection, a few dunders make it work natively with len(), in, indexing, and for:
class Inventory:
def __init__(self, items):
self._items = list(items)
def __repr__(self):
return f"Inventory({self._items})"
def __len__(self):
return len(self._items)
def __iter__(self):
return iter(self._items)
def __getitem__(self, index):
return self._items[index]
def __contains__(self, item):
return item in self._items
inv = Inventory(["apple", "banana", "cherry"])
print(len(inv))
print(inv[1])
print("apple" in inv)
for item in inv:
print(" -", item)
3
banana
True
- apple
- banana
- cherry
Each built-in just delegated to its dunder: len(inv) to __len__, inv[1] to __getitem__, in to __contains__, and the for loop to __iter__. (Implementing __getitem__ with integer indices alone is actually enough to make a class iterable via Python’s fallback protocol, but an explicit __iter__ is clearer and works with any storage.)
Context managers and callables
Two more dunders unlock two more language features. A class with __enter__ and __exit__ works in a with block — here a deterministic tracer that prints when it enters and exits:
class Section:
def __init__(self, label):
self.label = label
def __enter__(self):
print(f"enter {self.label}")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"exit {self.label}")
# return True to suppress an exception; False/None to let it propagate
with Section("compute") as s:
total = sum(i * i for i in range(5))
print("total:", total)
enter compute
exit compute
total: 30
And __call__ makes an instance callable like a function — perfect for a stateful “function” that remembers things between calls:
class Counter:
def __init__(self):
self.n = 0
def __call__(self):
self.n += 1
return self.n
count = Counter()
print(count(), count(), count()) # call the instance directly
print("total:", count.n)
1 2 3
total: 3
That last pattern is exactly how a PyTorch model is invoked — model(x) is an instance call routed through __call__, not a plain function. The dunder protocols are precisely how libraries make their objects feel like built-in language features: Pandas DataFrames respond to [], len, and for; SQLAlchemy turns User.name == "Aarav" into a SQL WHERE clause through __eq__.
In one breath
- Write
__repr__on every class — the default shows a useless memory address. __eq__makes value objects compare by field; pair it with a consistent__hash__or you breakset/dict.- Return
NotImplementedfrom__eq__for unrelated types. __len__,__iter__,__getitem__,__contains__make a class behave like a container.__enter__/__exit__powerwith;__call__makes an instance callable like a function.
Practice
Quick check
What’s next
Writing all these dunders by hand is tedious. Next comes dataclasses — a decorator that writes __init__, __repr__, and __eq__ for you, so you only fill in the interesting parts.
Practice this in an interview
All questionsObjects that compare equal must have identical hash values. Python enforces this expectation by setting `__hash__` to `None` whenever you define `__eq__` without `__hash__`, making the object unhashable and ineligible as a dict key or set member. To restore hashability you must define both.
`__repr__` is the developer-facing representation — unambiguous, ideally eval-able back to the object. `__str__` is the user-facing string — readable and concise. When only `__repr__` is defined, Python falls back to it for `str()` as well, so implement `__repr__` first.
Python sets support union, intersection, difference, and symmetric difference as both operators and methods, all running in O(min(m,n)) to O(m+n) time. They are useful for deduplication, membership testing in large collections, and computing overlaps between datasets — operations that would be expensive with lists.
Instance methods receive the instance as the first argument (`self`) and can read and modify instance state. Class methods receive the class as the first argument (`cls`) via `@classmethod` and are used for alternative constructors or class-level operations. Static methods receive no implicit argument and are plain functions namespaced inside a class.