Ten parts of this series taught individual methods. This one is the idea underneath them, and it is a single sentence:
Python’s syntax is a set of questions, and dunder methods are how your object answers.
len(x) is not a function that inspects x. It is Python asking x a question — x.__len__() — and reporting the answer. Same for a + b, for i in x, with f:, x[0], if x:, str(x), x(). None of them special-case built-in types. list answers the same questions your class can.
That is why a well-written Python class stops feeling bolted on. It is not imitating a built-in. It is answering the same questions.
Everything below runs on Python 3.12 and the output is pasted from a real session.
Watching Python ask
class Probe:
def __len__(self):
print(' Python asked __len__')
return 3
print(len(Probe()), bool(Probe()))
Python asked __len__
Python asked __len__
3 True
Two questions were asked. len() asked directly. bool() asked because there was no __bool__ and it falls back to __len__ — a non-zero length means truthy.
That fallback chain is the second thing worth knowing about the data model: most questions have a fallback. Python asks the specific question, then a more general one, then applies a default.
Creating and destroying
__new__ allocates. __init__ fills in. You will write __init__ constantly and __new__ almost never — it matters for immutable types, where there is no “after allocation” in which to assign anything.
Skip __del__. It runs at an unpredictable time, can be skipped entirely at interpreter exit, and swallows exceptions. Cleanup belongs in a context manager, below.
Representation: three questions, not two
Part 3 covered __repr__ and __str__. There is a third, and it is the one that makes your object work with f-strings:
class Temp:
def __init__(self, c): self.c = c
def __str__(self): return f"{self.c}C"
def __format__(self, spec):
if spec == 'f': return f"{self.c * 9/5 + 32:.1f}F"
if spec == 'k': return f"{self.c + 273.15:.2f}K"
if not spec: return str(self)
return format(self.c, spec)
t = Temp(100)
print(f"{t} | {t:f} | {t:k} | {t:>8.1f}")
100C | 212.0F | 373.15K | 100.0
f"{t:f}" passes the string "f" to __format__ and your object decides what it means. This is exactly how datetime makes %Y work — it is not a special case in f-strings, it is datetime.__format__ choosing to hand the spec to strftime.
The last branch delegates anything it does not recognise to the underlying number, which is the delegation pattern worth copying: interpret the specs you care about, pass the rest down.
Comparison, and the one you must not forget
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor): self.major, self.minor = major, minor
def __repr__(self): return f"Version({self.major}, {self.minor})"
def __eq__(self, o): return (self.major, self.minor) == (o.major, o.minor)
def __lt__(self, o): return (self.major, self.minor) < (o.major, o.minor)
v1, v2 = Version(1, 2), Version(1, 10)
print(v1 < v2, v1 >= v2, v1 != v2)
print(sorted([Version(2,0), Version(1,10), Version(1,2)]))
True False True
[Version(1, 2), Version(1, 10), Version(2, 0)]
@total_ordering fills in <=, > and >= from __eq__ and __lt__. Note Version(1, 2) < Version(1, 10) is True — comparing tuples, not strings, which is why version sorting done on strings puts 1.10 before 1.2.
!= came free: Python derives it from __eq__ unless you override it. Do not.
And the rule from Part 7 that catches everyone: define __eq__ and you must define __hash__, or your object becomes unhashable.
Attribute access
This is the layer most people never touch, and it explains @property.
class Loud:
def __getattr__(self, name):
return f"(no attribute {name!r}, and this ran instead)"
l = Loud()
l.real = 1
print(l.real)
print(l.missing)
1
(no attribute 'missing', and this ran instead)
__getattr__ is the fallback — it runs only when normal lookup fails. That makes it cheap and safe for proxies and lazy loading.
__getattribute__ is different and runs on every access:
class Watch:
def __init__(self): self.x = 1
def __getattribute__(self, name):
if not name.startswith('_'):
print(f' looked up {name!r}')
return object.__getattribute__(self, name)
w = Watch()
_ = w.x
looked up 'x'
Note it calls object.__getattribute__ rather than self.x, which would recurse forever. The same trap applies to __setattr__:
class Frozen:
def __init__(self, x):
object.__setattr__(self, 'x', x) # bypass our own __setattr__
def __setattr__(self, name, value):
raise AttributeError(f"{type(self).__name__} is read-only")
f = Frozen(1)
print(f.x)
try:
f.x = 2
except AttributeError as err:
print(type(err).__name__ + ':', err)
1
AttributeError: Frozen is read-only
__init__ has to bypass its own __setattr__ to set anything at all. This is roughly how @dataclass(frozen=True) works.
Descriptors: what @property actually is
A descriptor is an object that defines __get__ or __set__, and it controls what happens when it is accessed as a class attribute. @property is one. So are methods, classmethod and staticmethod.
Writing one directly pays off when the same validation repeats across fields:
class Positive:
def __set_name__(self, owner, name):
self.name = '_' + name
def __get__(self, obj, objtype=None):
if obj is None: return self
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name.lstrip('_')} must be positive, got {value}")
setattr(obj, self.name, value)
class Product:
price = Positive()
weight = Positive()
def __init__(self, price, weight):
self.price, self.weight = price, weight
p = Product(10, 2)
print(p.price, p.weight)
try:
Product(-1, 2)
except ValueError as err:
print(type(err).__name__ + ':', err)
10 2
ValueError: price must be positive, got -1
Two fields, one validator, and the error names the right field. __set_name__ is how the descriptor learns what it was called — Python passes it the attribute name at class creation.
As two @property pairs this is twelve lines of near-identical code. At five fields it is thirty.
Iteration
class Countdown:
def __init__(self, n): self.n = n
def __iter__(self):
current = self.n
while current > 0:
yield current
current -= 1
print(list(Countdown(4)))
a, b, *rest = Countdown(5)
print(a, b, rest)
[4, 3, 2, 1]
5 4 [3, 2, 1]
__iter__ as a generator is the shortest correct way to make something iterable — no separate iterator class, no __next__, no StopIteration to raise by hand.
Because it builds a fresh generator each call, the object can be iterated more than once. Return self from __iter__ and it exhausts after one pass, which is a common and confusing bug.
Unpacking works for free. So do list(), sum(), max(), in, and every comprehension.
Operators, and the reflected pair
class Metres:
def __init__(self, v): self.v = v
def __repr__(self): return f"Metres({self.v})"
def __add__(self, other):
if isinstance(other, Metres): return Metres(self.v + other.v)
return NotImplemented
def __radd__(self, other):
if other == 0: return self # makes sum() work
return NotImplemented
print(Metres(1) + Metres(2))
print(sum([Metres(1), Metres(2), Metres(3)]))
Metres(3)
Metres(6)
For a + b, Python asks a.__add__(b) first. If that returns NotImplemented, it asks b.__radd__(a). That is how 1 + your_object can work when the integer has no idea what your type is.
The __radd__ here exists purely so sum() works — sum starts from 0 and adds, so the first operation is 0 + Metres(1), which only your __radd__ can answer.
There are also in-place forms (__iadd__ for +=). Skip them unless mutation in place is genuinely faster; without one, += falls back to __add__ and rebinds, which is usually what you want.
Context managers
class Timer:
def __enter__(self):
self.events = ['enter']
return self
def __exit__(self, exc_type, exc, tb):
self.events.append('exit' if exc_type is None else f'exit({exc_type.__name__})')
return False
with Timer() as t2:
t2.events.append('body')
print(t2.events)
t3 = Timer()
try:
with t3:
raise ValueError('boom')
except ValueError:
pass
print(t3.events)
['enter', 'body', 'exit']
['enter', 'exit(ValueError)']
__exit__ runs whatever happened, and is told which exception is in flight. Returning False lets it continue; returning True swallows it, which you should do rarely and deliberately.
This is where cleanup belongs — not __del__.
Class creation
class Plugin:
registry = {}
def __init_subclass__(cls, /, name=None, **kw):
super().__init_subclass__(**kw)
Plugin.registry[name or cls.__name__.lower()] = cls
class Csv(Plugin, name='csv'): pass
class Json(Plugin): pass
print(Plugin.registry)
{'csv': <class '__main__.Csv'>, 'json': <class '__main__.Json'>}
__init_subclass__ runs when someone subclasses you, and accepts keyword arguments given in the class definition. Self-registering plugins with no decorator and no metaclass.
This is the hook that removed most legitimate uses of metaclasses. If you were about to write one, check whether __init_subclass__ and __set_name__ cover it. They usually do.
Pattern matching asks a question too
match is the newest part of the data model, and your classes can answer it — but only if you tell Python what the positional slots mean.
class Point:
__match_args__ = ('x', 'y')
def __init__(self, x, y): self.x, self.y = x, y
def describe(p):
match p:
case Point(0, 0): return 'origin'
case Point(0, y): return f'on the y axis at {y}'
case Point(x, 0): return f'on the x axis at {x}'
case Point(x, y) if x == y: return f'on the diagonal at {x}'
case Point(x, y): return f'at {x},{y}'
for p in [Point(0,0), Point(0,5), Point(3,0), Point(4,4), Point(1,2)]:
print(' ', describe(p))
origin
on the y axis at 5
on the x axis at 3
on the diagonal at 4
at 1,2
__match_args__ is a tuple naming which attributes the positional patterns map to. Without it, positional matching is an error rather than a silent miss:
class NoMatch:
def __init__(self, x): self.x = x
try:
match NoMatch(1):
case NoMatch(1): pass
except TypeError as err:
print(type(err).__name__ + ':', err)
TypeError: NoMatch() accepts 0 positional sub-patterns (1 given)
Keyword patterns — case NoMatch(x=1) — work without it, because they name the attribute directly. And dataclasses supply it for free, in field order:
@dataclass
class DC:
x: int
y: int
print('dataclass gets it free:', DC.__match_args__)
match DC(1, 2):
case DC(x=1, y=v): print(f' matched with y={v}')
dataclass gets it free: ('x', 'y')
matched with y=2
One caution: __match_args__ fixes an order, and reordering it later silently changes what every positional pattern means. Keyword patterns do not have that failure mode, which is a good reason to prefer them past two fields.
The async protocol is the same questions, awaited
Every protocol above has an async twin. with becomes async with and asks __aenter__ / __aexit__; for becomes async for and asks __aiter__ / __anext__.
import asyncio
class Fetcher:
async def __aenter__(self):
self.events = ['open']
return self
async def __aexit__(self, *exc):
self.events.append('close')
return False
class Ticker:
def __init__(self, n): self.n = n
def __aiter__(self):
self.i = 0
return self
async def __anext__(self):
if self.i >= self.n:
raise StopAsyncIteration
self.i += 1
await asyncio.sleep(0)
return self.i
async def main():
async with Fetcher() as f:
f.events.append('body')
print(' ', f.events)
print(' ', [x async for x in Ticker(3)])
asyncio.run(main())
['open', 'body', 'close']
[1, 2, 3]
Two details differ from the synchronous versions. __aiter__ is not a coroutine — it returns the async iterator directly, and only __anext__ is awaited. And the sentinel is StopAsyncIteration, not StopIteration; raising the wrong one produces a confusing RuntimeError rather than a clean stop.
Everything else is the shape you already know.
The questions, collected
| You write | Python asks | Falls back to |
|---|---|---|
repr(x) |
__repr__ |
the default <Class object at 0x…> |
str(x), print(x) |
__str__ |
__repr__ |
f"{x:spec}" |
__format__ |
__str__ for an empty spec |
x == y |
__eq__ |
identity (is) |
x != y |
__ne__ |
not __eq__ |
x < y |
__lt__ |
TypeError |
hash(x) |
__hash__ |
id-based, unless __eq__ is defined |
len(x) |
__len__ |
TypeError |
if x: |
__bool__ |
__len__, then always true |
x[k] |
__getitem__ |
TypeError |
for i in x |
__iter__ |
__getitem__ from index 0 |
k in x |
__contains__ |
__iter__, then __getitem__ |
a + b |
__add__ |
b.__radd__(a) |
x() |
__call__ |
TypeError |
with x: |
__enter__ / __exit__ |
TypeError |
x.missing |
__getattribute__ |
__getattr__, then AttributeError |
case C(a, b) |
__match_args__ |
TypeError for positional patterns |
async with x: |
__aenter__ / __aexit__ |
TypeError |
async for i in x |
__aiter__ / __anext__ |
TypeError |
What not to implement
The data model is large and most of it is not for you.
__del__ — unpredictable timing, skipped at exit, swallows exceptions. Use a context manager.
__getattribute__ — runs on every access, easy to make infinitely recursive, and slows the class down. __getattr__ covers nearly every real case.
Metaclasses — __init_subclass__ and __set_name__ cover the common reasons.
__slots__ by default — Part 10. It has two good reasons and neither is “it seems tidier”.
And the general rule: each dunder is a promise about how your object behaves. A promise nobody needed is just more to keep true. Implement __repr__ always, __eq__ and __hash__ for value types, and add the rest when a caller actually wants to write that syntax.
The five things
-
Python’s syntax is questions; dunder methods are answers. There is no special-casing of built-in types.
-
Most questions have a fallback chain —
booltolen,strtorepr,itertogetitem,addtoradd. -
@propertyis a descriptor. Write a descriptor directly when the same validation repeats across fields. -
Return
NotImplementedfrom operators you cannot handle, so the other operand gets its turn. -
Implementing a dunder is a promise. Make only the ones a caller will use.
The rest of the data model is in the language reference, and now it will read as a list of questions rather than a list of magic.