You can now write a class, build an inheritance hierarchy, and hide state behind a property. That is the mechanics of OOP, and mechanics alone will happily let you build something awful. This lesson is about the part nobody teaches: design — deciding what should be a class at all, what it should be made of, and how it should behave when the language pokes it.
Start with the code most Python programs are actually made of:
order = {"id": "ORD-1001", "items": [{"sku": "MILK-1L", "qty": 2, "price": 62.00}]}
total = sum(i["qty"] * i["price"] for i in order["items"])
It works. It also has no idea what it is. Nothing stops qty being -5, nothing stops price being the string "free", nothing catches i["prise"] until it explodes in production at 2 a.m., and 62.00 is a float, which means your money is approximate. The dictionary is a bag of anything, and a bag of anything cannot defend itself.
The fix is not “add validation everywhere.” The fix is to build a type that cannot be wrong — and to let Python’s own protocols make it feel like a native part of the language, so that len(order), for item in order, order == other, sorted(prices) and with draft as d: all just work. That is what this lesson builds, end to end.
Why this matters
Python’s OOP has a secret that takes most people years to notice: there is no interface keyword, no implements, and almost no place where inheritance is required. Instead, the language defines a set of protocols — informal contracts — and it calls your methods to fulfil them. Want len(x) to work? Write __len__. Want for i in x? Write __iter__. Want x == y to mean something? Write __eq__. These double-underscore methods (“dunder” methods, short for double underscore) are collectively called the data model, and they are the closest thing Python has to a type system’s interfaces.
That single idea reframes everything from Parts 1-3. In Java you inherit from a base class to become a thing. In Python you implement a couple of dunders and you are the thing — no base class, no registration, no declaration. A class with __len__ and __getitem__ is a sequence as far as every builtin is concerned. This is why “duck typing” is not a slogan but a mechanism: the interpreter genuinely does not care what your class inherits from, only which dunders it can find.
The second idea is about shape. Most classes you write are not clever behavioural abstractions — they are data with rules attached: an order, a line item, a price, a config, an API response. For those, @dataclass deletes the boilerplate (__init__, __repr__, __eq__) and lets you spend your attention on the rules instead. Reaching for a hand-written __init__ with six self.x = x lines is, in 2026, usually a sign you have not met dataclasses yet.
The third idea is the one that separates good designs from bad ones: composition over inheritance. Parts 2 and 3 taught you inheritance and the MRO. This part teaches you when not to use them. An Order is not a kind of anything — it has line items, and each line item has a price. “Has-a” beats “is-a” almost every time, and later in this lesson you will watch an inheritance hierarchy silently return the wrong rupee amount while the composed version returns the right one, with no error from either.
Hold on to one sentence for the rest of this lesson: your job is to make illegal states unrepresentable. Not “validated later” — unrepresentable. If a Money cannot be negative, the constructor should refuse. If an order in INR cannot hold a USD line, add() should refuse. Push every rule to the boundary and the rest of your program stops checking.
The data model: dunder methods are Python’s interfaces
Here is the mechanism, in one sentence: when you write an expression, the interpreter translates it into a dunder call on the object’s type. len(o) is not a function that inspects o; it is roughly type(o).__len__(o). o == p is type(o).__eq__(o, p). with o: is type(o).__enter__(o) then type(o).__exit__(o, ...).
Read those again and notice the word doing all the work: type(o). Special methods are looked up on the class, never on the instance. This is not pedantry — it is directly observable:
class Bag:
def __init__(self, items):
self.items = items
def __len__(self):
return len(self.items)
b = Bag(["a", "b", "c"])
print(len(b)) # => 3
b.__len__ = lambda: 99 # set an INSTANCE attribute
print(b.__len__()) # => 99 the attribute is really there, and callable
print(len(b)) # => 3 but len() never looked at the instance
b.__len__() finds your lambda, because explicit attribute access does check the instance first. len(b) ignores it completely, because implicit dunder invocation goes straight to the type. Every consequence in this lesson traces back to that rule.
The other half of the mechanism: Python never guesses. If the dunder is missing you get a TypeError that names the protocol you forgot.
class Empty:
pass
len(Empty()) # TypeError: object of type 'Empty' has no len()
for x in Empty(): ... # TypeError: 'Empty' object is not iterable
Empty()[0] # TypeError: 'Empty' object is not subscriptable
Empty()() # TypeError: 'Empty' object is not callable
Empty() + Empty() # TypeError: unsupported operand type(s) for +: 'Empty' and 'Empty'
with Empty(): ... # TypeError: 'Empty' object does not support the context manager protocol
Those messages are a lookup table in disguise. “has no len()” → write __len__. “not iterable” → write __iter__. “not subscriptable” → write __getitem__. Learn to read the noun and the error tells you the method.
Version note: the context-manager message is Python 3.11+. On 3.9/3.10 the same code raises a bare
AttributeError: __enter__, which is far less helpful — one of many small reasons to be on a modern Python.
The dunders that actually matter
There are around a hundred dunders. You will use these.
| You write | Python calls | Must return | Notes |
|---|---|---|---|
Thing(...) |
__init__(self, ...) |
None |
Initialises an already-created object; __new__ creates it |
repr(o), REPL echo, f"{o!r}" |
__repr__(self) |
str |
Always write this one. Unambiguous, for developers |
str(o), print(o), f"{o}" |
__str__(self) |
str |
For end users. Falls back to __repr__ if absent |
f"{o:>10.2f}", format(o, spec) |
__format__(self, spec) |
str |
Defaults to __str__ only for an empty spec |
o == p |
__eq__(self, other) |
bool or NotImplemented |
Defining it sets __hash__ = None — see below |
o != p |
__ne__ |
bool |
Auto-derived from __eq__; don’t write it |
hash(o), {o}, {o: v} |
__hash__(self) |
int |
Must agree with __eq__ and never change |
o < p, sorted(), min(), max() |
__lt__(self, other) |
bool or NotImplemented |
sorted() needs only __lt__ |
o <= p, o > p, o >= p |
__le__, __gt__, __ge__ |
bool |
Or get them free from @total_ordering |
len(o) |
__len__(self) |
non-negative int |
Also supplies truthiness |
o[k], o[1:3] |
__getitem__(self, key) |
anything | Also enables legacy iteration |
o[k] = v / del o[k] |
__setitem__ / __delitem__ |
None |
Mutable containers only |
for x in o, list(o), a, b = o |
__iter__(self) |
an iterator | The real iteration protocol |
x in o |
__contains__(self, x) |
bool |
Falls back to __iter__, then __getitem__ |
bool(o), if o:, not o |
__bool__(self) |
bool |
Falls back to __len__, then True |
o(...) |
__call__(self, ...) |
anything | Makes the instance callable |
with o as x: |
__enter__ / __exit__ |
__enter__'s value / a truthy-or-falsy flag |
__exit__ returning True swallows the exception |
o + p, o * n, o - p |
__add__, __mul__, __sub__ |
new object or NotImplemented |
Plus __radd__ etc. for the reflected side |
o.x (miss), o.x = v |
__getattr__, __setattr__ |
anything / None |
__getattr__ runs only after a normal lookup fails |
match o: case Thing(a, b) |
__match_args__ (class attr) |
tuple[str, ...] |
@dataclass generates it for you |
The diagram below is the whole mechanism in one picture. Read it left to right: you write an ordinary expression; the interpreter looks the matching dunder up on type(o) and never on o itself; your implementation runs; and the object it runs on is a composed model — an Order that has-a list of LineItem, each of which has-a Money — with every rule concentrated in one __post_init__ per class.
The six badges mark the things that cost people the most time: dunder lookup skips the instance entirely, so a monkey-patched o.__len__ does nothing (1); defining __eq__ silently sets __hash__ = None and your object becomes unhashable (2); a missing dunder is a TypeError, never a default (3); has-a beats is-a, and the MRO will silently pick your order of operations if you let it (4); a frozen Money value object is exact, hashable and safe to share where a bare float is none of those (5); and __post_init__ is the single gate that makes an invalid object impossible to construct (6).
__repr__ and __str__: how an object introduces itself
If you write exactly one dunder in your life, write __repr__. Without it, every object you print, log, or stare at in a debugger looks like this:
class Plain:
pass
print(repr(Plain())) # => <__main__.Plain object at 0x104a2f0e0>
That tells you the class and a memory address. It tells you nothing about the value, which is the only thing you wanted. Now the same object in a list of five, in a failing test’s diff, in a log line at 2 a.m. — five identical useless strings.
Python gives you two string conversions because there are two audiences:
__repr__is for developers. It should be unambiguous and, ideally, look like the code that would recreate the object. The convention isClassName(field=value, ...)with!ron the values.__str__is for end users. It should be readable. Currency formatting, units, a friendly sentence.
class Temp:
def __init__(self, c):
self.c = c
def __repr__(self):
return f"Temp(c={self.c!r})" # unambiguous — note !r on the value
def __str__(self):
return f"{self.c}°C" # readable
t = Temp(21.5)
print(repr(t)) # => Temp(c=21.5)
print(str(t)) # => 21.5°C
print(t) # => 21.5°C print() uses __str__
print([t, t]) # => [Temp(c=21.5), Temp(c=21.5)] containers use __repr__ !
print(f"{t} | {t!r}") # => 21.5°C | Temp(c=21.5)
That fourth line is the one people trip over. Containers always use repr on their elements, even when you print() the container. A list has no way to know whether you want the friendly form, so it picks the unambiguous one. This is exactly why a class with only __str__ still shows <__main__.Thing object at 0x...> inside a list — and why the fallback runs one way only:
class OnlyRepr:
def __repr__(self):
return "OnlyRepr(1)"
print(str(OnlyRepr())) # => OnlyRepr(1) str() falls back to __repr__
class OnlyStr:
def __str__(self):
return "friendly"
print(repr(OnlyStr())) # => <__main__.OnlyStr object at 0x...> NO fallback
str() falls back to __repr__; repr() never falls back to __str__. So __repr__ alone gives you a decent-everywhere object, while __str__ alone gives you an object that is only decent when printed directly. That asymmetry is the whole argument for the rule: always write __repr__; add __str__ only when the friendly form genuinely differs.
| Context | Calls | Notes |
|---|---|---|
repr(o) |
__repr__ |
Never falls back to __str__ |
str(o), print(o) |
__str__ → __repr__ |
Falls back if __str__ is missing |
REPL echo (>>> o) |
__repr__ |
Why the REPL is honest and print is polite |
f"{o}", "{}".format(o) |
__format__("") → __str__ |
Empty spec delegates to __str__ |
f"{o!r}" |
__repr__ |
!r forces repr — great in log messages |
f"{o!s}" / f"{o!a}" |
__str__ / ascii() |
!a escapes non-ASCII |
f"{o:>10.2f}" |
__format__(">10.2f") |
TypeError if you only wrote __str__ |
[o], (o,), {o}, {k: o} |
__repr__ on each element |
Containers never use str |
logging.info("%s", o) |
__str__ |
%r for repr |
| Debugger / traceback locals | __repr__ |
The 2 a.m. argument for writing it |
That __format__ row deserves a demonstration, because the failure is surprising:
class NoFormat:
def __str__(self):
return "hello"
f"{NoFormat()}" # => 'hello' empty spec -> __str__
f"{NoFormat():>10}" # TypeError: unsupported format string passed to NoFormat.__format__
The default object.__format__ accepts an empty spec only. The moment you ask for alignment or precision it refuses. Either implement __format__, or format the string instead: f"{str(o):>10}".
The good news, and the theme of the second half of this lesson: @dataclass writes a correct __repr__ for you, in exactly the recommended ClassName(field=value) shape. You will almost never hand-write one again.
__eq__, __hash__ and ordering
By default, == on your objects means is — same object or bust:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
print(Point(1, 2) == Point(1, 2)) # => False identical values, different objects
That is nearly always wrong for data. Two points at (1, 2) are the same point. So you define __eq__:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
print(Point(1, 2) == Point(1, 2)) # => True fixed!
{Point(1, 2)} # TypeError: unhashable type: 'Point'
You fixed equality and broke hashing. This is the single most surprising thing in the data model, so let us be precise about it.
Defining __eq__ sets __hash__ = None
When Python creates a class whose body defines __eq__ but not __hash__, it inserts __hash__ = None. Not “leaves it alone” — actively sets it to None, which makes the object unhashable: no sets, no dict keys, no functools.lru_cache, nothing.
print(Point.__hash__) # => None
This looks hostile until you see the contract it protects. Two rules bind __eq__ and __hash__:
- If
a == b, thenhash(a) == hash(b)must hold. (The reverse is not required — unequal objects may collide.) - An object’s hash must never change during its lifetime.
A dict finds a key by hashing it to a bucket, then comparing with == inside that bucket. If you redefine == so two objects are equal but leave the inherited identity-based hash, those two equal objects land in different buckets and the dict happily stores both — silently corrupting itself. Python’s response is not to guess a hash for you; it is to make the object unhashable and force you to decide. Rule 1 broken is a bug you would debug for a day. TypeError: unhashable type: 'Point' is a bug you fix in a minute.
The fix is to hash exactly the fields you compare:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented # see below
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y)) # SAME fields as __eq__
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
a, b = Point(1, 2), Point(1, 2)
print(a == b, hash(a) == hash(b)) # => True True
print(len({a, b})) # => 1 one logical point
print({a: "origin"}[b]) # => origin looked up by an equal object
hash((self.x, self.y)) — hashing a tuple of the fields — is the idiom. It is correct, fast, and it is exactly what @dataclass(frozen=True) generates.
| Situation | __eq__ |
__hash__ |
Result |
|---|---|---|---|
| Define neither | identity | identity | Hashable; == means is. Fine for entities/services |
Define __eq__ only |
yours | None |
Unhashable — TypeError: unhashable type: 'X' |
| Define both | yours | yours | Correct — hash the same fields you compare |
__eq__ + __hash__ = object.__hash__ |
yours | identity | Broken. Equal objects, different hashes. Never do this |
@dataclass (default) |
generated | None |
Unhashable — mutable, so this is the safe default |
@dataclass(frozen=True) |
generated | generated | Hashable and correct — the value-object recipe |
@dataclass(eq=False) |
identity | identity | Hashable by identity — the entity recipe |
@dataclass(unsafe_hash=True) |
generated | generated | Hashable but mutable — the name is a warning |
The last row deserves its warning:
@dataclass(unsafe_hash=True)
class Mutable:
a: int
m = Mutable(1)
d = {m: "one"}
m.a = 2 # mutate a field that the hash is computed from
print(m in d) # => False the key is now unfindable — with the object in hand
The entry is still in the dict. You simply can never reach it again, because it is filed under the old hash. This is the exact failure Rule 2 exists to prevent, and it is why mutable dataclasses are unhashable by default.
NotImplemented: the polite refusal
Look again at the isinstance guard. Without it, comparing to anything else explodes:
class Bad:
def __init__(self, x):
self.x = x
def __eq__(self, other):
return self.x == other.x # assumes `other` has .x
Bad(1) == "hello" # AttributeError: 'str' object has no attribute 'x'
a == b must never raise just because b is a different type — x == y is asked all over the standard library (by in, by .index(), by assertEqual) and it must always answer. The protocol is: when you do not know how to compare, return NotImplemented, and Python takes it from there. It tries the reflected operation (other.__eq__(self)), and if that also declines, it falls back to identity and returns False.
print(Point(1, 2) == "hello") # => False no exception
print(Point(1, 2) != "hello") # => True __ne__ derived automatically
| Value | What it is | Use for |
|---|---|---|
NotImplemented |
A singleton value you return |
“I can’t handle this type — Python, try the other side” |
NotImplementedError |
An exception you raise |
“This abstract/unfinished method must be overridden” |
None |
A value | Neither — returning None from __eq__ makes it truthy-ish and wrong |
False |
A value | “Definitely not equal” — blocks the reflected operation |
Confusing the first two is a rite of passage. return NotImplemented in __eq__; raise NotImplementedError in an abstract method (though Part 2’s abc module is the better tool).
Ordering: __lt__ and @total_ordering
Sorting your objects needs exactly one method — __lt__. That is all sorted(), min() and max() ever call.
class V:
def __init__(self, n):
self.n = n
def __lt__(self, other):
return self.n < other.n
def __repr__(self):
return f"V({self.n})"
print(sorted([V(3), V(1), V(2)])) # => [V(1), V(2), V(3)]
print(V(1) < V(2)) # => True
print(V(1) <= V(2)) # TypeError: '<=' not supported between instances of 'V' and 'V'
But < alone gives you a lopsided object: < works, <= does not. Worse, > appears to work:
print(V(1) > V(2)) # => False ... how?
Python evaluated V(1) > V(2) by trying V(1).__gt__(V(2)) (missing), then the reflected operation V(2).__lt__(V(1)) — which exists. So > works by accident and <= does not, because <='s reflection is __ge__, also missing. Every comparison operator has a mirror image:
| You write | Python tries | Then the reflection |
|---|---|---|
a < b |
a.__lt__(b) |
b.__gt__(a) |
a > b |
a.__gt__(b) |
b.__lt__(a) |
a <= b |
a.__le__(b) |
b.__ge__(a) |
a >= b |
a.__ge__(b) |
b.__le__(a) |
a == b |
a.__eq__(b) |
b.__eq__(a) → identity → False |
a + b |
a.__add__(b) |
b.__radd__(a) |
a * b |
a.__mul__(b) |
b.__rmul__(a) |
Writing all four comparisons by hand is tedious and easy to get subtly wrong. functools.total_ordering fills in the rest from __lt__ + __eq__:
from functools import total_ordering
@total_ordering
class V:
def __init__(self, n):
self.n = n
def __eq__(self, other):
if not isinstance(other, V):
return NotImplemented
return self.n == other.n
def __lt__(self, other):
if not isinstance(other, V):
return NotImplemented
return self.n < other.n
def __hash__(self):
return hash(self.n)
print(V(1) < V(2), V(1) <= V(2), V(2) > V(1), V(2) >= V(2)) # => True True True True
print([m for m in ("__lt__", "__le__", "__gt__", "__ge__") if m in V.__dict__])
# => ['__lt__', '__le__', '__gt__', '__ge__'] total_ordering added three
The cost is speed — the derived operators are implemented in terms of __lt__ and are a little slower than hand-written ones — and it is almost never worth caring about. Note that @dataclass(order=True) is the other route, and generates all four directly.
Containers, truth and callables
__len__, __iter__, __contains__, __getitem__
Four dunders turn your class into something that behaves like a native collection:
class Playlist:
def __init__(self, name, tracks):
self.name = name
self.tracks = list(tracks)
def __repr__(self):
return f"Playlist(name={self.name!r}, tracks={self.tracks!r})"
def __len__(self):
return len(self.tracks)
def __iter__(self):
return iter(self.tracks) # delegate to the list's iterator
def __contains__(self, title):
return any(t.lower() == title.lower() for t in self.tracks) # case-insensitive!
def __getitem__(self, i):
return self.tracks[i] # slices come free — self.tracks[0:2]
pl = Playlist("focus", ["Kolkata", "Aruna", "Nara"])
print(len(pl)) # => 3
print(list(pl)) # => ['Kolkata', 'Aruna', 'Nara']
print("nara" in pl) # => True your rule, not the list's
print(pl[0], pl[-1]) # => Kolkata Nara
print(pl[:2]) # => ['Kolkata', 'Aruna']
print(bool(pl)) # => True from __len__, free
Note __iter__ returning iter(self.tracks) — you do not write an iterator class, you hand back the list’s. And __contains__ is where composition pays: in on a Playlist means your definition of membership (case-insensitive titles), not the list’s.
The fallback chains
Both in and bool() have layered fallbacks, and knowing them explains a lot of “why did that work?”:
x in o resolves to |
When |
|---|---|
o.__contains__(x) |
If defined — always wins |
Loop over o.__iter__() comparing with == |
If no __contains__ |
Loop over o[0], o[1], … until IndexError |
If neither — the legacy protocol |
TypeError: argument of type 'X' is not iterable |
If none of the above |
bool(o) resolves to |
When |
|---|---|
o.__bool__() |
If defined — always wins, must return a real bool |
len(o) != 0 |
If no __bool__ but __len__ exists |
True |
If neither — every object is truthy by default |
That third row of the truth table is worth pausing on. An object with no __bool__ and no __len__ is always truthy — including an “empty” one. If your class has a notion of emptiness, give it __len__ (or __bool__) or if my_thing: will lie to you forever.
The __len__-implies-truthiness shortcut also has a sharp edge:
class Account:
def __init__(self, balance):
self.balance = balance
def __len__(self):
return int(self.balance) # a terrible idea
print(not Account(0.5)) # => True a ₹0.50 account is "empty"
__len__ must return a non-negative int, so it silently truncated. __len__ means a count of contained items — nothing else. Never map it onto a quantity:
class BadLen:
def __len__(self):
return -1
len(BadLen()) # ValueError: __len__() should return >= 0
The legacy __getitem__ iteration protocol is worth recognising because you will meet it in old code:
class OldSchool:
def __init__(self, data):
self.data = data
def __getitem__(self, i):
return self.data[i] # relies on IndexError to stop the loop
print(list(OldSchool(["x", "y", "z"]))) # => ['x', 'y', 'z'] no __iter__ at all!
Python calls o[0], o[1], o[2], o[3] and stops when IndexError arrives. It predates __iter__ and it still works. Write __iter__ in new code — it is explicit, it works for non-integer keys, and it does not depend on an exception as a loop terminator.
__call__: objects that behave like functions
__call__ makes an instance callable. That sounds like a party trick until you see what it replaces: a function that needs configuration.
class PercentOff:
def __init__(self, percent):
self.percent = percent
def __call__(self, price):
return round(price * (100 - self.percent) / 100, 2)
def __repr__(self):
return f"PercentOff(percent={self.percent!r})"
ten = PercentOff(10)
print(ten(2000)) # => 1800.0 it's an object, called like a function
print(callable(ten)) # => True
print([PercentOff(5), PercentOff(10)])
# => [PercentOff(percent=5), PercentOff(percent=10)] <- a lambda could never
print(list(map(PercentOff(50), [100, 200]))) # => [50.0, 100.0]
A closure or lambda does the same job, but a callable object has three advantages: it has a useful __repr__ (compare <function <lambda> at 0x104b2c860>), it can be compared and hashed if you make it a frozen dataclass, and its configuration is inspectable data rather than captured variables. That is precisely what you want for strategy objects — pricing rules, retry policies, validators — and it is the tool that makes the composition refactor later in this lesson work.
Context managers and operator overloading
__enter__ / __exit__
with is not just for files. Any class with __enter__ and __exit__ gets guaranteed setup/teardown — even when the body raises, returns, or breaks.
class Timer:
def __init__(self, label):
self.label = label
def __enter__(self):
print(f"[{self.label}] start")
return self # THIS is what `as t` binds
def __exit__(self, exc_type, exc, tb):
if exc_type is None:
print(f"[{self.label}] ok")
else:
print(f"[{self.label}] failed: {exc_type.__name__}: {exc}")
return False # do NOT swallow
with Timer("load") as t:
print(" working...", t.label)
# [load] start
# working... load
# [load] ok
try:
with Timer("parse"):
raise ValueError("bad row 7")
except ValueError as e:
print("caught outside:", e)
# [parse] start
# [parse] failed: ValueError: bad row 7
# caught outside: bad row 7
Two things trip people up. First, __enter__'s return value is what as binds — returning self is the common choice, but a file object returns the file, and contextlib’s decorators return whatever you yield. Return nothing and as x gives you None.
Second, and much nastier: __exit__'s return value decides whether the exception propagates.
class Swallow:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return True # almost always a bug
with Swallow():
raise ValueError("this vanishes")
print("we got here, and the ValueError is gone") # it really does print
A truthy __exit__ suppresses the exception entirely — no traceback, no log, nothing. This is how contextlib.suppress is implemented, and it is a legitimate tool used deliberately. It is also what accidentally happens when you end __exit__ with a return inside an if and let it fall through, or when you get clever. Return False, or return nothing at all (None is falsy) unless suppression is the entire point of the class.
__exit__ parameter |
On clean exit | On exception |
|---|---|---|
exc_type |
None |
the exception class, e.g. ValueError |
exc |
None |
the exception instance (its str() is the message) |
tb |
None |
the traceback object |
| Return value | ignored | truthy → suppress, falsy → propagate |
Operator overloading — and when to stop
Arithmetic dunders let +, -, * work on your types. For genuine value types (money, vectors, durations, matrices) this is a joy:
class Vec:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Vec(x={self.x!r}, y={self.y!r})"
def __add__(self, other):
if not isinstance(other, Vec):
return NotImplemented
return Vec(self.x + other.x, self.y + other.y)
def __mul__(self, k):
if not isinstance(k, (int, float)):
return NotImplemented
return Vec(self.x * k, self.y * k)
__rmul__ = __mul__ # so 3 * v works too
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
print(Vec(1, 2) + Vec(3, 4)) # => Vec(x=4, y=6)
print(Vec(1, 2) * 3) # => Vec(x=3, y=6)
print(3 * Vec(1, 2)) # => Vec(x=3, y=6) via __rmul__
print(abs(Vec(3, 4))) # => 5.0
Vec(1, 2) + 5 # TypeError: unsupported operand type(s) for +: 'Vec' and 'int'
The NotImplemented return is what produces that clean final TypeError. Raise your own TypeError instead and you break 5 + v for anyone who later writes an __radd__.
Which brings us to the most-hit operator trap:
print(sum([Vec(1, 2), Vec(3, 4)]))
# TypeError: unsupported operand type(s) for +: 'int' and 'Vec'
'int' and 'Vec' — where did an int come from? sum() starts at 0. Its first move is 0 + Vec(1, 2), int.__add__ declines, Python tries Vec.__radd__ (missing) and gives up. Two fixes, both legitimate: pass a start value with sum(vecs, start=Vec(0, 0)), or define __radd__ to absorb the zero.
| Operator dunders | Trigger | Reflected form |
|---|---|---|
__add__ / __sub__ |
a + b / a - b |
__radd__ / __rsub__ |
__mul__ / __truediv__ |
a * b / a / b |
__rmul__ / __rtruediv__ |
__floordiv__ / __mod__ / __pow__ |
a // b, a % b, a ** b |
__rfloordiv__, __rmod__, __rpow__ |
__iadd__ / __imul__ |
a += b / a *= b |
none — falls back to __add__ + rebind |
__neg__ / __pos__ / __abs__ |
-a / +a / abs(a) |
none — unary |
__and__ / __or__ / __xor__ |
a & b, `a |
b, a ^ b` |
Now the honest part. Operator overloading is a bad idea far more often than it is a good one. The test is simple: would a stranger predict the result without reading your source?
| Expression | Verdict | Why |
|---|---|---|
Money(62) + Money(360) |
✅ Good | One obvious meaning. + on money is addition |
Vec(1,2) + Vec(3,4) |
✅ Good | Established mathematical convention |
timedelta + datetime, Path / "sub" |
✅ Good | Stdlib precedent; reads naturally |
playlist1 + playlist2 |
🤔 Maybe | Concatenate? Merge and dedupe? Union of two sets? Ambiguous |
order + line_item |
❌ Bad | Write order.add(item) — the name carries the meaning |
user1 + user2 |
❌ Bad | Meaningless. What is the sum of two people? |
query & filter |
❌ Usually bad | Cute DSLs read well in the README and terribly in a stack trace |
| `config1 | config2` | 🤔 Maybe |
report >> file |
❌ Bad | C++ nostalgia. Nobody can guess this |
The rule that holds up: overload an operator only when there is exactly one meaning a reader could reasonably guess, and it matches an existing convention. Otherwise, a method with a verb in its name — order.add(item), playlist.merge(other) — is better code. A named method costs you five keystrokes and saves the next person five minutes.
dataclasses: the modern default
Here is a plain class holding three fields, written properly:
class Track:
def __init__(self, title, artist, seconds=0):
self.title = title
self.artist = artist
self.seconds = seconds
def __repr__(self):
return f"Track(title={self.title!r}, artist={self.artist!r}, seconds={self.seconds!r})"
def __eq__(self, other):
if not isinstance(other, Track):
return NotImplemented
return (self.title, self.artist, self.seconds) == (other.title, other.artist, other.seconds)
def __hash__(self):
return hash((self.title, self.artist, self.seconds))
Thirteen lines, with title spelled out eight separate times, and every one of those repetitions is a place to make a typo that no test will catch. Here is the same class:
from dataclasses import dataclass
@dataclass
class Track:
title: str
artist: str
seconds: int = 0
t = Track("Kolkata", "Bombay Dub", 372)
print(t) # => Track(title='Kolkata', artist='Bombay Dub', seconds=372)
print(t == Track("Kolkata", "Bombay Dub", 372)) # => True
print(Track("x", "y")) # => Track(title='x', artist='y', seconds=0)
@dataclass reads the class-level type annotations, and from them generates __init__, __repr__ and __eq__. The annotations are what make a name a field — a bare x = 5 with no annotation is just a class attribute and is ignored entirely. And note what the decorator does not do: those str and int hints are never enforced at runtime. Track(1, None, "nope") constructs happily. Hints are for you, your reader, and mypy.
By default you get __init__, __repr__, __eq__ and __match_args__ — and pointedly not __hash__ or ordering:
print(Track.__hash__) # => None
{t} # TypeError: unhashable type: 'Track'
sorted([Track("b", "x"), Track("a", "y")])
# TypeError: '<' not supported between instances of 'Track' and 'Track'
That is the __eq__/__hash__ rule from earlier, applied consistently: a default dataclass is mutable, mutable things must not be hashed, so __hash__ is None. You opt back in with frozen=True.
The decorator parameters
| Parameter | Default | What it does |
|---|---|---|
init |
True |
Generate __init__ from the fields |
repr |
True |
Generate ClassName(field=value, ...) |
eq |
True |
Generate __eq__ comparing the tuple of fields |
order |
False |
Generate __lt__ __le__ __gt__ __ge__ comparing the field tuple |
unsafe_hash |
False |
Force a __hash__ even when mutable — the name is the warning |
frozen |
False |
Block attribute assignment; with eq=True also generates __hash__ |
match_args |
True |
Generate __match_args__ for match/case (3.10+) |
kw_only |
False |
Make every field keyword-only — sidesteps field-ordering pain (3.10+) |
slots |
False |
Generate __slots__; smaller, faster, no __dict__ (3.10+) |
weakref_slot |
False |
Add __weakref__ to the slots (3.11+) |
The eq/frozen combination decides hashability, and the truth table is worth memorising:
eq |
frozen |
__hash__ |
Meaning |
|---|---|---|---|
True |
True |
generated from the fields | Value object. Hashable, immutable, safe to share |
True |
False |
None — unhashable |
The default. Mutable data holder |
False |
True |
object.__hash__ (identity) |
Rare: immutable but compared by identity |
False |
False |
object.__hash__ (identity) |
Entity. You supply __eq__/__hash__ by ID |
field() and the mutable-default fix
Give a dataclass a mutable default and it refuses to be created at all:
@dataclass
class Bad:
tags: list = []
# ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
This error is a gift. Consider the equivalent plain class, where Python says nothing at all:
class Plain:
def __init__(self, tags=[]): # the classic Python bug
self.tags = tags
p1, p2 = Plain(), Plain()
p1.tags.append("rock")
print(p1.tags, p2.tags) # => ['rock'] ['rock'] <- p2?!
print(p1.tags is p2.tags) # => True ONE list, shared by every instance
A default argument is evaluated once, when the def executes — so every Plain() that does not pass tags shares one list forever. Dataclasses detect this class of mistake and stop you. The fix is default_factory, a zero-argument callable run per instance:
from dataclasses import field
@dataclass
class Good:
tags: list[str] = field(default_factory=list)
g1, g2 = Good(), Good()
g1.tags.append("rock")
print(g1, g2) # => Good(tags=['rock']) Good(tags=[])
print(g1.tags is g2.tags) # => False a fresh list each time
field() parameter |
Default | What it does |
|---|---|---|
default |
MISSING |
A plain default (immutables only) |
default_factory |
MISSING |
Zero-arg callable, called per instance — list, dict, set, uuid4, Money.zero |
init |
True |
False → keep it out of __init__; compute it in __post_init__ |
repr |
True |
False → hide it from __repr__ (passwords, tokens, huge blobs) |
compare |
True |
False → exclude from __eq__ and ordering (timestamps, caches) |
hash |
None |
Follows compare; override only with a very good reason |
kw_only |
MISSING |
Make this one field keyword-only (3.10+) |
metadata |
None |
A read-only mapping for third-party tools; Python ignores it |
repr=False and compare=False earn their keep immediately:
@dataclass
class User:
name: str
password: str = field(repr=False) # never log this
last_seen: float = field(default=0.0, compare=False) # not part of identity
print(User("vinod", "hunter2", 1.5))
# => User(name='vinod', last_seen=1.5) password is gone
print(User("vinod", "hunter2", 1.5) == User("vinod", "hunter2", 99.0)) # => True
Field ordering
Dataclass fields become __init__ parameters in order, so the ordinary Python rule applies:
@dataclass
class Job:
name: str = "anon"
qty: int # no default, after one that has one
# TypeError: non-default argument 'qty' follows default argument
Three fixes: reorder so defaults come last; use kw_only=True on the class; or mark individual fields kw_only. This bites hardest with inheritance, where a base class’s defaulted field forces every subclass field to have a default too:
@dataclass
class Base:
a: int
b: str = "b"
@dataclass
class Child(Base):
c: float # inherits a, b — and b has a default
# TypeError: non-default argument 'c' follows default argument
@dataclass(kw_only=True) dissolves the whole problem, which is one more quiet argument for composition over inheritance.
frozen=True and __post_init__
frozen=True blocks attribute assignment after construction and, with eq=True, generates the matching __hash__:
@dataclass(frozen=True)
class Coord:
x: int
y: int
c = Coord(1, 2)
print(hash(c) is not None, len({Coord(1, 2), Coord(1, 2)})) # => True 1
c.x = 9 # dataclasses.FrozenInstanceError: cannot assign to field 'x'
FrozenInstanceError subclasses AttributeError, so except AttributeError catches it. To “change” a frozen object you build a new one — dataclasses.replace(c, x=9) copies every other field for you and re-runs validation.
frozen is not deep. Exactly like the tuples in Lists & Tuples, it freezes the slots, not the objects they point at:
@dataclass(frozen=True)
class Box:
name: str
items: list = field(default_factory=list)
b = Box("b")
b.items.append("x") # frozen object, mutable field — perfectly legal
print(b) # => Box(name='b', items=['x'])
hash(b) # TypeError: unhashable type: 'list'
Frozen generated a __hash__ that hashes the field tuple — and hashing a tuple hashes its elements, so a list field makes the whole object unhashable at runtime. A value object should hold only hashable fields: use tuple, not list.
__post_init__ runs at the end of the generated __init__ and is where every invariant belongs:
@dataclass
class Rect:
w: float
h: float
area: float = field(init=False) # computed, not passed in
def __post_init__(self):
if self.w <= 0 or self.h <= 0:
raise ValueError(f"sides must be positive, got {self.w}x{self.h}")
self.area = self.w * self.h
print(Rect(3, 4)) # => Rect(w=3, h=4, area=12)
Rect(0, 4) # ValueError: sides must be positive, got 0x4
On a frozen class, __post_init__ cannot use plain assignment — it hits the same wall as everyone else:
@dataclass(frozen=True)
class Norm:
code: str
def __post_init__(self):
self.code = self.code.upper()
Norm("inr") # dataclasses.FrozenInstanceError: cannot assign to field 'code'
The escape hatch is to call the unfrozen setter directly, which is exactly what the generated __init__ does internally:
@dataclass(frozen=True)
class Norm:
code: str
def __post_init__(self):
object.__setattr__(self, "code", self.code.strip().upper())
print(Norm(" inr ")) # => Norm(code='INR')
slots=True
slots=True replaces the per-instance __dict__ with a fixed set of descriptors. Objects get smaller and attribute access gets faster, and typos become errors:
@dataclass(slots=True)
class P:
x: int
y: int
p = P(1, 2)
p.typo = 1 # AttributeError: 'P' object has no attribute 'typo'
p.__dict__ # AttributeError: 'P' object has no attribute '__dict__'
Without slots, p.typo = 1 silently succeeds and you debug it later. The memory difference is real, not theoretical — 200,000 three-field objects, measured with tracemalloc:
| Representation | sys.getsizeof (one object) |
200k objects, real heap | Notes |
|---|---|---|---|
dict |
184 B | 44.8 MB | Every key string re-referenced per instance |
@dataclass |
48 B + 296 B __dict__ |
25.6 MB | Key-sharing dicts help a lot |
@dataclass(slots=True) |
56 B | 19.2 MB | ~25% under a plain dataclass, ~57% under dicts |
NamedTuple |
64 B | 22.4 MB | Immutable, tuple-compatible |
Use slots=True on value objects you create in bulk. Two caveats: it forbids class-level defaults outside field() in some edge cases, and — famously — zero-argument super() breaks inside a slots=True dataclass, because the decorator builds a new class object and the old closure cell points at the original:
@dataclass(slots=True)
class Child(Base):
def show(self):
return "Child->" + super().show()
# TypeError: super(type, obj): obj must be an instance or subtype of type
Use the explicit super(Child, self).show() — or, better, take the hint and compose instead.
The module helpers
| Helper | Returns | Use for |
|---|---|---|
fields(obj_or_cls) |
tuple of Field objects |
Introspection: names, types, defaults, metadata |
asdict(obj) |
dict, recursively |
Serialisation. Nested dataclasses become nested dicts |
astuple(obj) |
tuple, recursively |
Positional export; DB rows |
replace(obj, **changes) |
a new object | The only sane way to “edit” a frozen instance — re-runs __post_init__ |
is_dataclass(x) |
bool |
Works on both classes and instances |
MISSING |
sentinel | Tells “no default” apart from a default of None |
from dataclasses import asdict, astuple, fields, replace
t = Track("Kolkata", "Bombay Dub", 372)
print(asdict(t)) # => {'title': 'Kolkata', 'artist': 'Bombay Dub', 'seconds': 372}
print(astuple(t)) # => ('Kolkata', 'Bombay Dub', 372)
print(replace(t, seconds=400))
# => Track(title='Kolkata', artist='Bombay Dub', seconds=400)
⚠️ asdict() is recursive and deep-copies every value it walks. On a large object graph in a hot loop that is genuinely expensive — reach for astuple, a hand-written to_dict, or a serialisation library if it shows up in a profile.
dataclass vs NamedTuple vs TypedDict vs plain class vs pydantic
Five ways to hold structured data. They are not interchangeable, and picking wrong is a design smell that compounds.
dict |
NamedTuple |
TypedDict |
@dataclass |
plain class | pydantic.BaseModel |
|
|---|---|---|---|---|---|---|
| Runtime type | dict |
tuple |
dict |
your class | your class | your class |
| Access | o["k"] |
o.k and o[0] |
o["k"] only |
o.k |
o.k |
o.k |
| Mutable | yes | no | yes | yes (frozen=True → no) |
yes | yes (configurable) |
| Typo caught | ❌ KeyError at runtime |
✅ AttributeError |
❌ only by a type checker | ✅ AttributeError |
✅ | ✅ |
| Validates at runtime | ❌ | ❌ | ❌ | ❌ (unless you write __post_init__) |
❌ | ✅ coerces + validates |
__init__/__repr__/__eq__ |
n/a | free | n/a | free | hand-written | free |
| Hashable | no | ✅ always | no | only if frozen=True |
by identity | configurable |
| Ordered/sortable | no | ✅ free (tuple order) | no | order=True |
hand-written | configurable |
| Methods & properties | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
Unpacks a, b = o |
❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Equals a plain tuple | no | ⚠️ yes | no | no | no | no |
| Memory (200k, 3 fields) | 44.8 MB | 22.4 MB | 44.8 MB | 25.6 MB / 19.2 MB slots | same | highest |
| Dependency | stdlib | stdlib | stdlib | stdlib | stdlib | third-party |
Two rows there are traps in disguise. First, a TypedDict is just a dict — the annotation is a message to your type checker and nothing happens at runtime:
from typing import TypedDict
class TrackTD(TypedDict):
title: str
seconds: int
td: TrackTD = {"title": 1, "nonsense": True} # no error at all
print(type(td)) # => <class 'dict'>
td.title # AttributeError: 'dict' object has no attribute 'title'
isinstance(td, TrackTD) # TypeError: TypedDict does not support instance and class checks
Second, a NamedTuple compares equal to a plain tuple, which is occasionally handy and occasionally a silent bug:
from typing import NamedTuple
class TrackNT(NamedTuple):
title: str
artist: str
seconds: int = 0
t = TrackNT("Nara", "Bombay Dub", 300)
print(t == ("Nara", "Bombay Dub", 300)) # => True equal to a bare tuple!
print(len(t), list(t)) # => 3 ['Nara', 'Bombay Dub', 300]
print(t._replace(seconds=999)) # => TrackNT(title='Nara', artist='Bombay Dub', seconds=999)
t.seconds = 5 # AttributeError: can't set attribute
A dataclass deliberately does not do that — TrackDC(...) == ("Nara", ...) is False, and a dataclass is never equal to a different class even with identical fields. That is usually what you want.
| Use this | When |
|---|---|
dict |
Genuinely dynamic keys; short-lived JSON you immediately parse into something better |
TypedDict |
You must stay a dict (an existing API boundary, **kwargs, a JSON payload) but want a type checker’s help |
NamedTuple |
A small immutable record that must behave like a tuple — unpack, index, sort, use as a dict key. Fixed forever |
@dataclass |
The default. Any data-holding class: mutable or frozen, with methods, defaults, and validation |
@dataclass(frozen=True, slots=True) |
Value objects: Money, Coord, Version. Immutable, hashable, cheap, created in bulk |
| plain class | Behaviour-first objects with little state: services, clients, strategies, context managers |
pydantic.BaseModel |
Data crossing a trust boundary — HTTP request bodies, config files, third-party JSON. You want coercion, precise errors, and a JSON schema |
The pydantic line is the one that matters most in practice, so let us be blunt about the difference. A dataclass’s type hints are documentation; pydantic’s are enforcement.
@dataclass
class TrackDC:
title: str
seconds: int
TrackDC(1, "not a number") # constructs fine — hints are not checked
# => TrackDC(title=1, seconds='not a number')
pydantic would reject that with a structured, field-by-field error. So: inside your own code, where you construct the objects, a dataclass plus a __post_init__ is enough — you control the inputs, and pydantic’s per-instance validation cost buys you little. At the edge, where bytes arrive from a network or a YAML file, use pydantic and convert to your domain types once. Reaching for pydantic everywhere is over-engineering; hand-rolling isinstance checks over an untrusted JSON body is under-engineering. (pydantic is third-party: python3 -m venv .venv && source .venv/bin/activate && pip install pydantic — on Windows, .venv\Scripts\activate.)
Composition over inheritance
Parts 2 and 3 taught you inheritance. Here is when not to use it.
Inheritance is an “is-a” relationship, and it is the tightest coupling in the language. A subclass depends on its parent’s methods, its parent’s attribute names, and the order in which its parent calls things. Change the parent and every subclass can break. Composition is a “has-a” relationship: your object holds another object and calls it. That is a normal method call, and normal method calls are easy to reason about, test, and replace.
The classic argument for composition is combinatorial. Suppose orders can be discounted and gift-wrapped. With inheritance you write TenPercentOrder(Order) and GiftWrappedOrder(Order) — and then somebody needs both:
class TenPercentOrder(Order):
def subtotal(self):
s = super().subtotal()
return Money(s.amount * Decimal("0.9"), s.currency)
class GiftWrappedOrder(Order):
def subtotal(self):
return super().subtotal() + Money(Decimal("50"))
class TenPercentGiftWrappedOrder(TenPercentOrder, GiftWrappedOrder):
pass
Add express delivery and loyalty points and you need TenPercentGiftWrappedExpressLoyaltyOrder. Four features means fifteen classes. That is the well-known problem. Here is the less well-known problem, and it is much worse. Run it on a ₹484 order:
plain : INR 484.00
10% off : INR 435.60
gift wrap : INR 534.00
both (via the MRO) : INR 480.60
MRO : ['TenPercentGiftWrappedOrder', 'TenPercentOrder', 'GiftWrappedOrder', 'Order']
INR 480.60. Where does that come from? Follow the MRO from Part 2: TenPercentOrder.subtotal runs first, calls super().subtotal() which lands on GiftWrappedOrder.subtotal, which adds ₹50 to the ₹484 base — and then the 10% discount applies to the result. So (484 + 50) × 0.9 = 480.60. You just discounted the gift wrap, and gave away ₹5 you never intended to.
Nobody wrote that decision down. The class declaration order — (TenPercentOrder, GiftWrappedOrder) — silently chose the order of operations, and swapping the two bases would silently change the invoice. There is no error, no warning, just a number that is quietly wrong.
Now compose instead. Make each rule a small callable object and hold a tuple of them:
@dataclass(frozen=True, slots=True)
class PercentOff:
percent: int
def __post_init__(self):
if not 0 <= self.percent <= 100:
raise ValueError(f"percent must be 0-100, got {self.percent}")
def __call__(self, subtotal: Money) -> Money:
cut = subtotal.amount * Decimal(self.percent) / Decimal(100)
return Money(subtotal.amount - cut, subtotal.currency)
@dataclass(frozen=True, slots=True)
class Checkout:
order: Order # has-a
rules: tuple[Rule, ...] = () # has-a — order of application is EXPLICIT
def total(self) -> Money:
running = self.order.subtotal()
for rule in self.rules:
running = rule(running)
return running
no rules -> INR 484.00
10% off -> INR 435.60
+INR 50.00 gift wrap -> INR 534.00
10% off + +INR 50.00 gift wrap -> INR 485.60
INR 485.60 — 484 × 0.9 + 50. The discount applies to the goods, the gift wrap is charged at full price, and the reason it comes out right is that you wrote the order down in a tuple instead of leaving it to a linearisation algorithm. The ₹5 difference between the two designs is exactly 10% of the gift wrap fee. Two designs, same classes, different invoice, no error from either.
Everything else gets better too. There is one Checkout class instead of fifteen. Rules are data: PercentOff(10) == PercentOff(10) is True, they are hashable, they have real reprs, they can be loaded from a config file, unit-tested alone in one line, and swapped at runtime. And Rule is a typing.Protocol — structural typing — so a rule does not inherit from anything at all; it merely has to be callable with the right shape. The theme of this whole lesson, one more time: the interface is the method, not the base class.
Inheritance (is-a) |
Composition (has-a) |
|
|---|---|---|
| Coupling | Tightest in the language — parent’s internals, names, call order | Loose — a normal method call through a small interface |
| Combining features | Combinatorial explosion of classes | Add another item to a list |
| Order of operations | Decided by the MRO, invisibly | Explicit in your code |
| Changing behaviour at runtime | Impossible — the class is fixed at construction | Swap the collaborator; it is just an attribute |
| Testing a piece alone | Needs the whole hierarchy | Instantiate the one small object |
| Reading it | Jump between five files to find the real method | The collaborator is right there in __init__ |
| Type relationship | isinstance passes |
Use typing.Protocol for structural typing |
| Good for | Genuine specialisation, stable frameworks, ABCs defining a contract | Almost everything else |
When inheritance is still right: a true, stable is-a where the subclass is substitutable for the parent everywhere (Liskov); an ABC that defines a contract (class Rule(ABC) with @abstractmethod); framework extension points the framework designed for it (class MyModel(models.Model)); and Exception subclasses, which are pure taxonomy.
One warning about delegation. It is tempting to forward everything automatically with __getattr__:
class Wrapper:
def __init__(self, order):
self._order = order
def __getattr__(self, name):
return getattr(self._order, name)
w = Wrapper(order)
print(w.order_id) # => ORD-X works
print(w.subtotal()) # => INR 484.00 works
len(w) # TypeError: object of type 'Wrapper' has no len()
len(w) fails even though w._order has a perfectly good __len__ — because implicit dunder lookup goes to the type and never triggers __getattr__. That is the very first rule in this lesson coming back to collect. Automatic delegation quietly drops every protocol the wrapped object had, and the AttributeError it raises names the inner class, which is baffling in a stack trace. Forward the handful of methods you actually mean to forward — explicitly, like Checkout.__len__ above.
Modelling the domain: value objects vs entities
Domain modelling has one classification that does most of the work: is this thing defined by its attributes, or by its identity?
A value object has no identity. ₹62 is ₹62; there is no “which one.” Two with the same attributes are the same thing. They should be immutable, compared by value, and hashable — @dataclass(frozen=True, slots=True).
An entity has identity that outlives its attributes. Order ORD-1001 is still ORD-1001 after you change its address, add an item, and ship it. Two entities are the same if their IDs match, whatever else differs — @dataclass(eq=False) with a hand-written __eq__/__hash__ on the ID.
| Value object | Entity | |
|---|---|---|
| Identity | None — it is its attributes | An ID that persists through change |
| Equality | All fields equal → same thing | Same ID → same thing, even if fields differ |
| Mutable | No — build a new one | Yes — its state is meant to evolve |
| Hashable | Yes (frozen generates it) | Yes, by ID |
| Recipe | @dataclass(frozen=True, slots=True) |
@dataclass(eq=False) + __eq__/__hash__ on the ID |
| Shared safely | Yes — nobody can change it | No — hand out copies or make it read-only |
| Examples | Money, Coord, DateRange, EmailAddress, Sku |
Order, User, Account, Shipment |
| Smell if wrong | An “immutable” thing you keep mutating | Two User("vinod") treated as one person |
Why Money(amount, currency) beats a bare float
This is the canonical value object, and every one of the arguments generalises.
Problem with price: float |
What Money does |
|---|---|
0.1 + 0.2 != 0.3 — binary floats cannot represent decimal fractions |
Wraps Decimal: Decimal("0.1") + Decimal("0.2") == Decimal("0.3") is True |
total = 62.00 * 3 → 186.00000000000000000000 under the hood |
Exact decimal arithmetic, no drift over a million rows |
Nothing stops price = -5 |
__post_init__ raises ValueError: Money cannot be negative: got -5 |
Nothing stops usd_price + inr_price |
__add__ raises ValueError: cannot mix INR and USD |
| The currency lives in another column, another variable, or a comment | It is inside the object; they cannot be separated |
62.0 prints as 62.0 |
__str__ gives INR 62.00; __repr__ gives the exact Decimal |
| Rounding rules re-implemented at every call site | One place: the class |
price * qty might silently be price * price |
__mul__ accepts int only; Money * Money is a TypeError |
The general principle is called primitive obsession: representing a domain concept with a str, int or float because it is stored that way. An email address is not a str — a str can be "not an email". An order ID is not a str — you can concatenate two of them. A percentage is not a float — a float can be -3000. Every time you wrap a primitive in a tiny frozen dataclass with a __post_init__, you move a whole class of bugs from “caught in production, maybe” to “impossible to construct.”
You do not have to wrap everything — sku: str in the lab stays a str, because the only rule is “not blank” and it lives fine in the LineItem. The test is: does this primitive have rules of its own, or rules that travel with it? Money does (currency, non-negativity, decimal precision). A SKU, here, does not.
Type hints on the model
Hints on a domain model earn more than hints anywhere else, because the model is what everything else touches. unit_price: Money tells mypy — and the reader, and your editor’s autocomplete — that a float is not welcome. Three notes:
- Use built-in generics:
list[LineItem],tuple[Rule, ...],dict[str, Money]. Thetyping.Listform has been legacy since 3.9. - Use
X | None, notOptional[X](3.10+). - For a method returning its own class, quote it —
def zero(cls) -> "Money":— or addfrom __future__ import annotations. But know the trade-off: that import turns all annotations into strings, sofields(Money)[0].typebecomes the string'Decimal'instead of the class. Libraries that read annotations at runtime care about this.
Hands-on lab
Everything here is pure standard library — no pip install needed. (For the habit: python3 -m venv .venv && source .venv/bin/activate; on Windows .venv\Scripts\activate and use python instead of python3.) This lab targets Python 3.12+ — slots= and kw_only= need 3.10+, and some error messages differ on older versions:
python3 --version
# Python 3.12.3
Create shop.py and append each step as you go, running python3 shop.py after each one. Every output below is exact.
Step 1 — Start with dicts, and feel the pain.
order = {
"id": "ORD-1001",
"items": [
{"sku": "MILK-1L", "qty": 2, "price": 62.00},
{"sku": "RICE-5KG", "qty": 1, "price": 360.00},
],
}
print(sum(i["qty"] * i["price"] for i in order["items"])) # => 484.0
try:
print(order["items"][0]["prise"]) # a typo — no autocomplete, no check
except KeyError as e:
print("KeyError:", e) # => KeyError: 'prise'
order["items"][0]["qty"] = -5 # nonsense, silently accepted
order["items"].append({"sku": "TEA-250G", "qty": 1}) # no price at all!
print(order["items"][0]) # => {'sku': 'MILK-1L', 'qty': -5, 'price': 62.0}
try:
sum(i["qty"] * i["price"] for i in order["items"])
except KeyError as e:
print("KeyError:", e, "<- raised far from the line that caused it")
# => KeyError: 'price' <- raised far from the line that caused it
print(0.1 + 0.2 == 0.3, 0.1 + 0.2) # => False 0.30000000000000004
What just happened: five distinct failures in twelve lines. The typo was invisible until runtime. A negative quantity was accepted. A priceless item was accepted, and the error surfaced in a totally different function from the one that caused it. And money as a float already cannot represent ₹0.30. Nothing here is Python’s fault — a dict promises to hold anything and it kept its promise.
Step 2 — Money: a frozen value object that cannot be wrong.
from dataclasses import dataclass, field, replace, asdict, FrozenInstanceError
from decimal import Decimal
from functools import total_ordering
from typing import Protocol
@total_ordering
@dataclass(frozen=True, slots=True)
class Money:
"""A value object: no identity, compared by value, immutable, self-validating."""
amount: Decimal
currency: str = "INR"
def __post_init__(self) -> None:
# normalise first — frozen, so assign through object.__setattr__
if not isinstance(self.amount, Decimal):
object.__setattr__(self, "amount", Decimal(str(self.amount)))
if self.amount < 0:
raise ValueError(f"Money cannot be negative: got {self.amount}")
if len(self.currency) != 3 or not self.currency.isupper():
raise ValueError(f"currency must be a 3-letter ISO code, got {self.currency!r}")
@classmethod
def zero(cls, currency: str = "INR") -> "Money":
return cls(Decimal("0"), currency)
def _same_currency(self, other: "Money") -> None:
if self.currency != other.currency:
raise ValueError(f"cannot mix {self.currency} and {other.currency}")
def __str__(self) -> str: # for humans
return f"{self.currency} {self.amount:,.2f}"
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented # let Python raise the right TypeError
self._same_currency(other)
return Money(self.amount + other.amount, self.currency)
def __radd__(self, other): # so sum() works
return self if other == 0 else NotImplemented
def __mul__(self, n):
if not isinstance(n, int):
return NotImplemented
return Money(self.amount * n, self.currency)
__rmul__ = __mul__
def __lt__(self, other): # @total_ordering fills in <= > >=
if not isinstance(other, Money):
return NotImplemented
self._same_currency(other)
return self.amount < other.amount
m = Money(Decimal("62.00"))
print(repr(m)) # __repr__ — generated, unambiguous
print(str(m)) # __str__ — yours, for humans
print(m + Money(Decimal("360.00")), m * 3, 3 * m)
print(m == Money(Decimal("62.00")), m == Money(Decimal("62.00"), "USD"))
print(len({Money(Decimal("62.00")), Money(Decimal("62.00"))})) # frozen -> hashable
print(sorted([Money(Decimal("360")), Money(Decimal("62"))]))
print(m < Money(Decimal("360")), m >= Money(Decimal("62")))
print(sum([Money(Decimal("62")), Money(Decimal("360"))]))
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))
Money(amount=Decimal('62.00'), currency='INR')
INR 62.00
INR 422.00 INR 186.00 INR 186.00
True False
1
[Money(amount=Decimal('62'), currency='INR'), Money(amount=Decimal('360'), currency='INR')]
True True
INR 422.00
True
Now watch it defend itself:
for bad in [(Decimal("-1"), "INR"), (Decimal("1"), "inr"), (Decimal("1"), "RUPEES")]:
try:
Money(*bad)
except ValueError as e:
print("ValueError:", e)
try:
Money(Decimal("62")) + Money(Decimal("1"), "USD")
except ValueError as e:
print("ValueError:", e)
try:
m + 5
except TypeError as e:
print("TypeError:", e)
try:
m.amount = Decimal("1")
except FrozenInstanceError as e:
print(f"{type(e).__name__}:", e)
print(replace(m, amount=Decimal("70.00")), "| original still", m)
ValueError: Money cannot be negative: got -1
ValueError: currency must be a 3-letter ISO code, got 'inr'
ValueError: currency must be a 3-letter ISO code, got 'RUPEES'
ValueError: cannot mix INR and USD
TypeError: unsupported operand type(s) for +: 'Money' and 'int'
FrozenInstanceError: cannot assign to field 'amount'
INR 70.00 | original still INR 62.00
What just happened: one small class, and a negative price, a lowercase currency, a mixed-currency sum and a mutation are now all impossible, not “validated somewhere.” @dataclass gave you __init__, the exact __repr__ and __eq__; frozen=True gave you __hash__ (hence len({...}) == 1 for two equal amounts) and blocked assignment; @total_ordering turned one __lt__ into four operators; __radd__ made sum() work; and NotImplemented from __add__ let Python produce the idiomatic TypeError instead of a custom one.
Step 3 — LineItem has-a Money.
@dataclass(frozen=True, slots=True)
class LineItem:
sku: str
qty: int
unit_price: Money # has-a — not "class LineItem(Money)"
def __post_init__(self) -> None:
if not self.sku.strip():
raise ValueError("sku must not be blank")
if self.qty <= 0:
raise ValueError(f"qty must be positive, got {self.qty}")
@property
def total(self) -> Money:
return self.unit_price * self.qty
def __str__(self) -> str:
return f"{self.sku:<10} x{self.qty:<2} @ {str(self.unit_price):>10} = {self.total}"
li = LineItem("MILK-1L", 2, Money(Decimal("62.00")))
print(repr(li))
print(str(li))
print(li == LineItem("MILK-1L", 2, Money(Decimal("62.00"))), len({li, li}))
for bad in [("MILK-1L", 0, m), ("MILK-1L", -5, m), (" ", 1, m)]:
try:
LineItem(*bad)
except ValueError as e:
print("ValueError:", e)
LineItem(sku='MILK-1L', qty=2, unit_price=Money(amount=Decimal('62.00'), currency='INR'))
MILK-1L x2 @ INR 62.00 = INR 124.00
True 1
ValueError: qty must be positive, got 0
ValueError: qty must be positive, got -5
ValueError: sku must not be blank
What just happened: the first composition. unit_price: Money is has-a — LineItem does not inherit from Money, it holds one, so Money’s rules come along automatically and LineItem only enforces its own. The generated __repr__ nests beautifully. total is a @property returning a Money, not a float, so the type survives the multiplication. And equal line items collapse in a set, because both classes are frozen all the way down.
Step 4 — Order: an entity that has-a list of LineItem.
@dataclass(eq=False) # eq=False: we write identity equality ourselves
class Order:
order_id: str
currency: str = "INR"
items: list[LineItem] = field(default_factory=list) # NOT items: list = []
def __eq__(self, other):
if not isinstance(other, Order):
return NotImplemented
return self.order_id == other.order_id # an ENTITY: eq by id
def __hash__(self):
return hash(self.order_id)
def __len__(self) -> int:
return len(self.items)
def __iter__(self):
return iter(self.items)
def __contains__(self, sku: str) -> bool:
return any(i.sku == sku for i in self.items)
def add(self, item: LineItem) -> None:
if item.unit_price.currency != self.currency:
raise ValueError(f"{item.sku} is priced in {item.unit_price.currency}, "
f"order {self.order_id} is in {self.currency}")
self.items.append(item)
def subtotal(self) -> Money:
# start= matters: sum([]) returns the int 0, not Money
return sum((i.total for i in self.items), start=Money.zero(self.currency))
o = Order("ORD-1001")
o.add(LineItem("MILK-1L", 2, Money(Decimal("62.00"))))
o.add(LineItem("RICE-5KG", 1, Money(Decimal("360.00"))))
print(len(o)) # __len__
for item in o: # __iter__
print(" ", item)
print("MILK-1L" in o, "BREAD" in o) # __contains__
print(bool(o), bool(Order("ORD-EMPTY"))) # __bool__ falls back to __len__
print("subtotal:", o.subtotal())
print("empty:", Order("ORD-EMPTY").subtotal())
print(o == Order("ORD-1001"), o is Order("ORD-1001"), len({o, Order("ORD-1001")}))
print(o == "ORD-1001")
try:
o.add(LineItem("VPN-YR", 1, Money(Decimal("9.99"), "USD")))
except ValueError as e:
print("ValueError:", e)
a, b = Order("A"), Order("B")
a.add(LineItem("X", 1, Money(Decimal("1"))))
print(len(a), len(b), a.items is b.items) # default_factory: separate lists
2
MILK-1L x2 @ INR 62.00 = INR 124.00
RICE-5KG x1 @ INR 360.00 = INR 360.00
True False
True False
subtotal: INR 484.00
empty: INR 0.00
True False 1
False
ValueError: VPN-YR is priced in USD, order ORD-1001 is in INR
1 0 False
What just happened: Order is now a first-class citizen of the language. len(o), for item in o, "MILK-1L" in o and if o: all work, and bool(Order("ORD-EMPTY")) is False for free via __len__. It is an entity, so o == Order("ORD-1001") is True despite one having items and the other none — same ID, same order — and o == "ORD-1001" returns False rather than raising, because __eq__ returned NotImplemented. a.items is b.items is False, proving default_factory built a fresh list per instance. And the currency invariant now lives in exactly one place.
Step 5 — OrderDraft: a real context manager.
class OrderDraft:
def __init__(self, order_id: str, currency: str = "INR") -> None:
self.order = Order(order_id, currency)
self.committed: Order | None = None
def __enter__(self) -> Order: # what `as d` binds
print(f" [open {self.order.order_id}]")
return self.order
def __exit__(self, exc_type, exc, tb) -> bool:
if exc_type is None:
self.committed = self.order
print(f" [commit {self.order.order_id}: {len(self.order)} items, "
f"{self.order.subtotal()}]")
else:
print(f" [rollback {self.order.order_id}: {exc_type.__name__}: {exc}]")
return False # False = do NOT swallow the exception
draft = OrderDraft("ORD-1002")
with draft as d:
d.add(LineItem("COFFEE-1", 1, Money(Decimal("480.00"))))
d.add(LineItem("SUGAR-1K", 2, Money(Decimal("55.00"))))
print("committed:", draft.committed.subtotal())
draft2 = OrderDraft("ORD-1003")
try:
with draft2 as d:
d.add(LineItem("GHEE-500", 1, Money(Decimal("620.00"))))
d.add(LineItem("OIL-1L", 0, Money(Decimal("165.00")))) # boom
except ValueError as e:
print("caught outside:", e)
print("committed:", draft2.committed)
[open ORD-1002]
[commit ORD-1002: 2 items, INR 590.00]
committed: INR 590.00
[open ORD-1003]
[rollback ORD-1003: ValueError: qty must be positive, got 0]
caught outside: qty must be positive, got 0
committed: None
What just happened: commit-on-success, rollback-on-failure, with the same with syntax as open(). __enter__ returned the Order, so as d bound the order and not the draft. __exit__ ran on both paths — that is the guarantee with buys you. And because it returned False, the ValueError still reached the caller: the draft cleaned up and you were told. Return True there and the bad order would vanish silently.
Step 6a — The wrong way: an inheritance tree.
class TenPercentOrder(Order):
def subtotal(self):
s = super().subtotal()
return Money(s.amount * Decimal("0.9"), s.currency)
class GiftWrappedOrder(Order):
def subtotal(self):
return super().subtotal() + Money(Decimal("50"))
class TenPercentGiftWrappedOrder(TenPercentOrder, GiftWrappedOrder):
pass
def build(cls):
x = cls("ORD-X")
x.add(LineItem("MILK-1L", 2, Money(Decimal("62.00"))))
x.add(LineItem("RICE-5KG", 1, Money(Decimal("360.00"))))
return x
print("plain :", build(Order).subtotal())
print("10% off :", build(TenPercentOrder).subtotal())
print("gift wrap :", build(GiftWrappedOrder).subtotal())
print("both (via the MRO) :", build(TenPercentGiftWrappedOrder).subtotal())
print("MRO :", [c.__name__ for c in TenPercentGiftWrappedOrder.__mro__[:4]])
plain : INR 484.00
10% off : INR 435.60
gift wrap : INR 534.00
both (via the MRO) : INR 480.60
MRO : ['TenPercentGiftWrappedOrder', 'TenPercentOrder', 'GiftWrappedOrder', 'Order']
What just happened: look at INR 480.60 and hold on to it. The MRO ran TenPercentOrder.subtotal → super() → GiftWrappedOrder.subtotal → base ₹484 + ₹50 = ₹534 → ×0.9 = ₹480.60. The gift wrap got discounted. Nobody decided that; the order of the base classes did.
Step 6b — The right way: compose rule objects.
class Rule(Protocol):
"""Structural type: anything callable Money -> Money is a Rule. No subclassing."""
def __call__(self, subtotal: Money) -> Money: ...
@dataclass(frozen=True, slots=True)
class PercentOff:
percent: int
def __post_init__(self) -> None:
if not 0 <= self.percent <= 100:
raise ValueError(f"percent must be 0-100, got {self.percent}")
def __call__(self, subtotal: Money) -> Money: # a callable OBJECT
cut = subtotal.amount * Decimal(self.percent) / Decimal(100)
return Money(subtotal.amount - cut, subtotal.currency)
def __str__(self) -> str:
return f"{self.percent}% off"
@dataclass(frozen=True, slots=True)
class FlatFee:
fee: Money
label: str = "fee"
def __call__(self, subtotal: Money) -> Money:
return subtotal + self.fee
def __str__(self) -> str:
return f"+{self.fee} {self.label}"
@dataclass(frozen=True, slots=True)
class Checkout:
order: Order # has-a
rules: tuple[Rule, ...] = () # has-a — order of application is explicit
def total(self) -> Money:
running = self.order.subtotal()
for rule in self.rules:
running = rule(running)
return running
def __len__(self) -> int: # delegate, on purpose
return len(self.order)
def __iter__(self):
return iter(self.order)
base = build(Order)
wrap = FlatFee(Money(Decimal("50")), "gift wrap")
express = FlatFee(Money(Decimal("40")), "express")
for rules in [(), (PercentOff(10),), (wrap,), (PercentOff(10), wrap),
(PercentOff(10), wrap, express)]:
desc = " + ".join(str(r) for r in rules) or "no rules"
print(f" {desc:<38} -> {Checkout(base, rules).total()}")
c = Checkout(base, (PercentOff(10),))
print("len(c):", len(c), "| skus:", [i.sku for i in c])
print("rules are data:", PercentOff(10) == PercentOff(10), len({PercentOff(10), PercentOff(10)}))
try:
PercentOff(150)
except ValueError as e:
print("ValueError:", e)
no rules -> INR 484.00
10% off -> INR 435.60
+INR 50.00 gift wrap -> INR 534.00
10% off + +INR 50.00 gift wrap -> INR 485.60
10% off + +INR 50.00 gift wrap + +INR 40.00 express -> INR 525.60
len(c): 2 | skus: ['MILK-1L', 'RICE-5KG']
rules are data: True 1
ValueError: percent must be 0-100, got 150
What just happened: ₹485.60, not ₹480.60. Same two features, ₹5 apart, and this one is right because the tuple (PercentOff(10), wrap) says the order out loud. Adding express delivery took zero new classes. PercentOff(10) == PercentOff(10) is True and hashable, because the rules are frozen dataclasses — they are data you could store in a config table. Checkout forwards __len__/__iter__ explicitly, and Rule is a Protocol, so PercentOff implements the interface without inheriting from anything.
Step 7 — Out to JSON.
import json
print(asdict(li))
print(json.dumps(asdict(li), default=str))
try:
json.dumps(asdict(li))
except TypeError as e:
print("TypeError:", e)
{'sku': 'MILK-1L', 'qty': 2, 'unit_price': {'amount': Decimal('62.00'), 'currency': 'INR'}}
{"sku": "MILK-1L", "qty": 2, "unit_price": {"amount": "62.00", "currency": "INR"}}
TypeError: Object of type Decimal is not JSON serializable
What just happened: asdict() walked the whole graph recursively — the nested Money became a nested dict without you writing a line. json still cannot encode a Decimal (encoding it as a float would undo the entire point of the class), so default=str serialises it as the exact string "62.00". That is the right answer: money crosses the wire as a string, not a float.
You have now taken a dict-shaped mess and, in seven steps, turned it into a model where a negative quantity, a blank SKU, a mixed-currency total, an out-of-range discount and a mutated price are all unrepresentable — while len(), in, for, sorted(), sum(), with and == all behave exactly as a Python programmer would expect.
Common mistakes and troubleshooting
| Symptom / traceback | Cause | Fix |
|---|---|---|
TypeError: unhashable type: 'Point' |
You defined __eq__, so Python set __hash__ = None |
Define __hash__ returning hash((self.a, self.b)) over the same fields, or use @dataclass(frozen=True), or drop __eq__ |
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory |
tags: list = [] — one list shared by every instance |
tags: list[str] = field(default_factory=list) |
dataclasses.FrozenInstanceError: cannot assign to field 'x' |
Assigning to a frozen=True instance (often inside __post_init__) |
Build a new one with replace(obj, x=9). Inside __post_init__ only, use object.__setattr__(self, "x", v) |
TypeError: non-default argument 'qty' follows default argument |
A field without a default follows one with a default — including inherited fields | Reorder, or @dataclass(kw_only=True), or field(kw_only=True) on the one field |
AttributeError: 'str' object has no attribute 'x' from __eq__ |
No isinstance guard — you assumed other is your type |
if not isinstance(other, X): return NotImplemented |
Your __eq__ returns True but the object vanishes from a dict/set |
Hash computed from a field you later mutated (unsafe_hash=True) |
Never hash mutable state. Freeze the object, or hash an immutable ID only |
Object prints as <__main__.Order object at 0x104a2f0e0> |
No __repr__ (or you only wrote __str__) |
Write __repr__ — or just use @dataclass, which generates it |
print(obj) is friendly but print([obj]) is not |
Containers always use __repr__ on elements, never __str__ |
Write __repr__; __str__ is the optional extra |
TypeError: unsupported format string passed to X.__format__ |
f"{o:>10}" on a class with only __str__ |
Implement __format__, or format the string: f"{str(o):>10}" |
TypeError: '<=' not supported between instances of 'V' and 'V' — but < works |
Only __lt__ defined; <= has no reflection to fall back on |
@total_ordering (needs __lt__ + __eq__) or @dataclass(order=True) |
TypeError: unsupported operand type(s) for +: 'int' and 'Money' from sum() |
sum() starts at 0, so the first add is 0 + Money |
sum(xs, start=Money.zero()), or define __radd__ to absorb 0 |
TypeError: object of type 'X' has no len() |
No __len__ — Python never guesses a default |
Write __len__ (and get truthiness free) |
TypeError: 'X' object is not iterable |
No __iter__ and no __getitem__ |
Write __iter__, usually return iter(self._items) |
TypeError: 'X' object does not support the context manager protocol |
Missing __enter__/__exit__. 3.9/3.10 say AttributeError: __enter__ |
Implement both, or use @contextlib.contextmanager |
An exception inside with disappears silently |
__exit__ returned something truthy |
return False (or nothing) unless suppression is the class’s purpose |
__post_init__ never runs |
It is only called by a generated __init__ — you wrote init=False or your own __init__ |
Drop your __init__, or call self.__post_init__() from it |
Type hints do nothing; Money("abc") constructs |
Dataclass hints are never enforced at runtime | Validate in __post_init__, run mypy in CI, or use pydantic at trust boundaries |
TypeError: super(type, obj): obj must be an instance or subtype of type |
Zero-arg super() inside a @dataclass(slots=True) class — the decorator built a new class |
Use super(Child, self), drop slots=True, or compose instead |
AttributeError: 'X' object has no attribute 'typo' after adding slots=True |
Slots forbid new attributes — this is the feature working | Add the field properly, or remove slots=True |
fields(X)[0].type is the string 'int', not int |
from __future__ import annotations stringifies all annotations |
Use typing.get_type_hints(X) to resolve them, or drop the import |
hash(obj) raises TypeError: unhashable type: 'list' on a frozen dataclass |
Frozen is not deep — a list field makes the field tuple unhashable |
Use tuple fields in value objects |
A wrapper forwards .method() but len(w) fails |
Implicit dunder lookup goes to the type, skipping __getattr__ |
Forward the dunders explicitly: def __len__(self): return len(self._inner) |
Three of these cost the most hours.
1. __eq__ deleting __hash__ — the invisible edit. You add __eq__ to make tests read nicely, and three weeks later something unrelated fails with TypeError: unhashable type. Nothing in your diff mentions __hash__, because Python inserted __hash__ = None on your behalf. Check it in one line — print(MyClass.__hash__) — and if it says None, that is why. The permanent fix is to stop hand-writing this pair: @dataclass(frozen=True) gets both right, every time. And remember the deeper rule it protects: never hash a field you will later mutate. The dict does not re-file the key when you change it; the entry becomes unreachable even when you are holding the original object.
2. __post_init__ silently not running. __post_init__ is not magic — it is one line at the bottom of the __init__ that @dataclass generates. Write your own __init__, or pass init=False, and nothing calls it. Your validation vanishes, no error, and invalid objects flow downstream. If a rule “isn’t firing,” check that the generated __init__ still exists. The same trap catches field(init=False) fields that you forget to set in __post_init__ — reading one then raises AttributeError on an object that looks fully constructed.
3. Mutable state hiding inside a “frozen” object. frozen=True stops you rebinding the attribute; it says nothing about the object the attribute points at. Box(name="b", items=[]) will happily let anyone do box.items.append("x") forever, and hash(box) will fail at runtime with TypeError: unhashable type: 'list' — a confusing error on a class you believe is immutable. This is the same one-level-deep rule that governs tuples. If a dataclass is a value object, every field must be immutable and hashable: tuple, not list; frozenset, not set; another frozen dataclass, not a mutable one.
Cheat-sheet
| Syntax | What it does |
|---|---|
def __init__(self, ...) |
Initialise an already-created object → None |
def __repr__(self) |
Always write this. ClassName(field=value); used by REPL, containers, debuggers |
def __str__(self) |
Human form; print()/str()/f"{o}". Falls back to __repr__ |
def __format__(self, spec) |
f"{o:>10.2f}". Without it, any non-empty spec raises TypeError |
def __eq__(self, other) |
==. Sets __hash__ = None. Return NotImplemented for foreign types |
def __hash__(self) |
return hash((self.a, self.b)) — the same fields __eq__ compares |
def __lt__(self, other) |
<, and all that sorted()/min()/max() need |
@total_ordering |
__lt__ + __eq__ → <= > >= for free |
def __len__(self) |
len(o); non-negative int; also gives truthiness |
def __bool__(self) |
if o:. Falls back to __len__, then to True |
def __iter__(self) |
for x in o; return iter(self._items) |
def __contains__(self, x) |
x in o; falls back to __iter__ then __getitem__ |
def __getitem__(self, k) |
o[k], o[1:3]; enables legacy iteration |
def __call__(self, ...) |
o(...) — the strategy-object pattern |
def __enter__/__exit__ |
with o as x:. __exit__ returning truthy suppresses the exception |
def __add__/__mul__ |
o + p, o * n. Return NotImplemented, not TypeError |
def __radd__(self, other) |
Reflected +; needed for sum() (which starts at 0) |
return NotImplemented |
“Try the other operand” — a value |
raise NotImplementedError |
“Subclass must override” — an exception |
type(o).__len__(o) |
What len(o) really does — the type, never the instance |
@dataclass |
Generates __init__, __repr__, __eq__, __match_args__ |
@dataclass(frozen=True) |
+ immutable + __hash__ → the value-object recipe |
@dataclass(order=True) |
+ __lt__ __le__ __gt__ __ge__ on the field tuple |
@dataclass(slots=True) |
+ __slots__: smaller, faster, typo-proof (3.10+) |
@dataclass(kw_only=True) |
Every field keyword-only — kills field-ordering errors (3.10+) |
@dataclass(eq=False) |
Keep identity ==/hash → the entity recipe |
@dataclass(unsafe_hash=True) |
Hash a mutable object. The name is the warning |
x: int |
A field. No annotation = not a field |
x: list = field(default_factory=list) |
The mutable-default fix — fresh list per instance |
field(init=False) |
Not an __init__ arg; set it in __post_init__ |
field(repr=False) |
Hide from __repr__ (passwords, blobs) |
field(compare=False) |
Exclude from __eq__/ordering (timestamps, caches) |
field(kw_only=True) |
This one field is keyword-only |
def __post_init__(self) |
Runs at the end of the generated __init__ — put invariants here |
object.__setattr__(self, "x", v) |
Assign inside a frozen __post_init__ |
replace(obj, x=9) |
New object with one field changed; re-runs __post_init__ |
asdict(obj) / astuple(obj) |
Recursive dict / tuple. ⚠️ deep-copies — costly in hot loops |
fields(obj) / is_dataclass(x) |
Introspection |
class X(NamedTuple) |
Immutable, tuple-compatible record; ⚠️ == a plain tuple |
class X(TypedDict) |
A plain dict with hints. Zero runtime effect |
class X(Protocol) |
Structural typing — implement the shape, inherit nothing |
Money(Decimal("62"), "INR") |
Wrap primitives that have rules; never float for money |
python3 -m venv .venv && source .venv/bin/activate |
Only needed for third-party libs (e.g. pydantic) |
Interview and exam questions
Q: What is a dunder method, and how does Python decide to call one?
A: A method with double underscores on both sides that implements part of Python’s data model — the protocols the language itself invokes. When you write an expression, the interpreter translates it into a dunder call on the type: len(o) becomes roughly type(o).__len__(o), o == p becomes type(o).__eq__(o, p). The key detail is that implicit dunder lookup skips the instance entirely — set o.__len__ = lambda: 99 and len(o) is unaffected. This is why Python needs no interface keyword: implementing the method is implementing the interface.
Q: What is the difference between __repr__ and __str__, and which should you write?
A: __repr__ is for developers — unambiguous, ideally looking like the constructor call (Money(amount=Decimal('62.00'), currency='INR')). __str__ is for end users — readable (INR 62.00). Always write __repr__, because str() falls back to __repr__ but repr() never falls back to __str__, and because containers, the REPL, debuggers and tracebacks all use repr. That last point bites: print(obj) may look fine while print([obj]) shows <__main__.Obj object at 0x...>. Add __str__ only when the friendly form really differs. @dataclass generates a correct __repr__ for free.
Q: Why does defining __eq__ make my class unhashable, and why should it return NotImplemented rather than False for a foreign type?
A: Two halves of writing __eq__ correctly. Unhashable: Python sets __hash__ = None in any class body that defines __eq__ without __hash__, so hash(o), sets and dict keys raise TypeError: unhashable type: 'X'. It protects an invariant — if a == b then hash(a) must equal hash(b) — because redefining == while keeping the inherited identity hash would put two “equal” objects in different buckets and silently corrupt every dict holding them. Python refuses to guess. Fix it by defining __hash__ over the same fields (return hash((self.a, self.b))), or better, use @dataclass(frozen=True), which generates both correctly; and never hash a field you later mutate, or the key becomes unreachable even with the original object in hand. NotImplemented: it means “I don’t know how to compare with this — Python, try the other side.” Python then tries the reflected other.__eq__(self) and only falls back to identity (False) if that also declines. Returning False outright blocks the other type from ever declaring equality with yours, breaking legitimate cases like a Money subclass or a test double. And NotImplemented (a value you return) is not NotImplementedError (an exception you raise in an abstract method).
Q: What does sorted() actually require, and what does @total_ordering add?
A: Only __lt__. sorted(), min() and max() are defined entirely in terms of <. But __lt__ alone gives you a lopsided class: < works, <= raises TypeError, and > works by accident via the reflected b.__lt__(a). @functools.total_ordering derives __le__, __gt__ and __ge__ from __lt__ plus __eq__, at a small speed cost. @dataclass(order=True) is the alternative and generates all four directly from the field tuple.
Q: What does @dataclass generate, and what does it deliberately not?
A: It reads the class’s type annotations and generates __init__, __repr__, __eq__ and __match_args__. It deliberately does not generate __hash__ (the class is mutable, so it is set to None) or ordering (order=False). frozen=True opts into immutability and a generated __hash__; order=True opts into comparisons. It also does not enforce the type hints — Track(1, None, "x") constructs happily. Hints are for readers and mypy.
Q: Why does tags: list = [] raise, when the same thing in a plain __init__ is legal?
A: Both are the same bug; only one is caught. A default is evaluated once, when the def/class body executes, so every instance that doesn’t pass a value shares one list — p1.tags.append("rock") then shows up in p2.tags. Dataclasses detect mutable defaults and refuse with ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory. The fix is field(default_factory=list), a zero-argument callable run per instance. In 3.12 the check is hash-based, so your own mutable classes are caught too.
Q: When would you use frozen=True, and what does it not protect you from?
A: For value objects — things defined by their attributes with no identity: Money, Coord, Version, a pricing rule. You get immutability, a correct __hash__, and safety in sharing them. What it does not give you is deep immutability: it freezes the attribute bindings, not the objects behind them. A frozen=True dataclass with items: list still allows obj.items.append(...), and hash(obj) then dies with TypeError: unhashable type: 'list'. Value objects must hold only hashable fields — tuple not list. Also, __post_init__ cannot use plain assignment on a frozen class; use object.__setattr__(self, "x", v), and use dataclasses.replace() to “change” one afterwards.
Q: Compare dataclass, NamedTuple, TypedDict and pydantic. When do you reach for each?
A: NamedTuple is an immutable tuple with names — hashable and sortable for free, unpacks, but it equals a plain tuple and can never gain mutability. TypedDict is a plain dict at runtime; the annotations exist purely for a type checker, so {"title": 1, "junk": True} is accepted and isinstance doesn’t even work — use it only when you must stay a dict. @dataclass is the default for anything with behaviour, defaults or validation: real attributes, generated dunders, frozen/slots/order when you want them, but hints are unenforced. pydantic actually validates and coerces at runtime and is third-party. The rule: dataclasses inside your own code, where you control construction and a __post_init__ is enough; pydantic at trust boundaries — HTTP bodies, config files, third-party JSON — then convert to your domain types once.
Q: Explain composition over inheritance with a concrete failure of inheritance.
A: Inheritance is “is-a” and the tightest coupling available; composition is “has-a” — hold a collaborator and call it. Beyond the obvious combinatorial explosion (discount × gift wrap × express = a class per combination), the sharper problem is that the MRO silently picks your order of operations. With TenPercentOrder(Order) and GiftWrappedOrder(Order) combined as TenPercentGiftWrappedOrder(TenPercentOrder, GiftWrappedOrder), the super() chain computes (484 + 50) × 0.9 = 480.60 — discounting the gift wrap. Composing rule objects in an explicit tuple gives 484 × 0.9 + 50 = 485.60, the intended number. Same classes, ₹5 apart, no error either way. Composition also makes rules testable alone, comparable, swappable at runtime, and loadable from config. Keep inheritance for genuine substitutable specialisation, ABCs defining a contract, framework extension points, and exception taxonomies.
Q: What is a value object versus an entity, and why does Money beat a float?
A: A value object has no identity — it is its attributes, so two ₹62 are the same money. Make it @dataclass(frozen=True, slots=True): immutable, hashable, compared by value. An entity has an ID that survives change — Order ORD-1001 is the same order after you add items — so compare by ID with @dataclass(eq=False) plus __eq__/__hash__ on the ID. Money beats float on five counts: floats are inexact (0.1 + 0.2 != 0.3) while Decimal is exact; nothing stops a negative float, while __post_init__ raises; nothing stops adding USD to INR, while __add__ raises; the currency can’t drift into another column because it lives inside the object; and rounding/formatting rules live in one class instead of every call site. It is the cure for primitive obsession — using a str/int/float for a concept that has rules of its own.
Q (coding): Make this class hashable, sortable and printable, correctly.
class Version:
def __init__(self, major, minor, patch):
self.major, self.minor, self.patch = major, minor, patch
A: Do not hand-write it:
@dataclass(frozen=True, order=True, slots=True)
class Version:
major: int
minor: int
patch: int
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
print(sorted([Version(1, 2, 3), Version(1, 0, 0)]))
# => [Version(major=1, minor=0, patch=0), Version(major=1, minor=2, patch=3)]
frozen=True gives immutability plus __hash__; order=True gives all four comparisons on the field tuple — and because fields compare in declaration order, major dominates minor dominates patch, which is exactly semantic-version ordering. slots=True makes them cheap. The generated __repr__ is unambiguous, and __str__ is the only line worth writing yourself.
Q (coding): Why does sum(prices) fail on a list of Money, and what are the two fixes?
A: sum() starts from 0, so the first operation is 0 + Money(...). int.__add__ returns NotImplemented, Python looks for Money.__radd__, doesn’t find it, and raises TypeError: unsupported operand type(s) for +: 'int' and 'Money' — note it says 'int', which is the clue. Fix one: sum(prices, start=Money.zero("INR")). Fix two: define __radd__ as return self if other == 0 else NotImplemented. Prefer start= for an aggregation that must know its currency when the list is empty — with no start, sum([]) returns the int 0, not a zero-rupee Money.
Key takeaways
- Dunder methods are Python’s interfaces, and they are looked up on the type.
len(o)istype(o).__len__(o)— the instance__dict__is skipped, which is why a monkey-patchedo.__len__does nothing and why__getattr__delegation silently drops every protocol. Implement the method and you are the thing; no base class, no registration. Miss it and you get aTypeErrornaming the protocol, never a silent default. - Always write
__repr__;__str__is optional.str()falls back to__repr__, butrepr()never falls back to__str__— and containers, the REPL, debuggers and tracebacks all userepr. Better still, let@dataclassgenerate it. - Defining
__eq__sets__hash__ = None. It is deliberate: equal objects must hash equally, so Python refuses to guess and makes you decide. Define__hash__over the same fields, or use@dataclass(frozen=True), which gets the whole pair right. Never hash a field you later mutate — the key becomes unreachable even with the original object in hand. return NotImplemented, don’traise. Comparison and arithmetic dunders should decline unknown types with theNotImplementedvalue so Python can try the reflected operation and produce the idiomaticTypeError.NotImplementedErroris a different thing, for abstract methods.@dataclassis the default for data-holding classes. It generates__init__,__repr__,__eq__and__match_args__from annotations;frozen=Trueadds immutability and__hash__;order=Trueadds comparisons;slots=Truecuts memory ~25% and catches typos.field(default_factory=list)is the mutable-default fix, and__post_init__is the one place invariants belong — on a frozen class, assign viaobject.__setattr__and “edit” withreplace().- Type hints on a dataclass are documentation, not enforcement.
Money("abc")constructs. Validate in__post_init__for code you control; use pydantic at trust boundaries where untrusted bytes arrive.NamedTuplefor immutable tuple-compatible records (but it equals a plain tuple);TypedDictwhen you must stay adict(it does nothing at runtime). - Composition beats inheritance — and the MRO will prove it. Inherited feature-combinations don’t just explode combinatorially; the linearisation silently chooses your order of operations, turning ₹485.60 into ₹480.60 with no error. Rules as small callable frozen dataclasses in an explicit tuple are testable, comparable, swappable and config-driven. Keep inheritance for genuine substitutable specialisation, ABCs, framework hooks and exception taxonomies — and forward dunders explicitly rather than via
__getattr__. - Model value objects and entities differently, and make illegal states unrepresentable. A value object is its attributes (
@dataclass(frozen=True, slots=True)); an entity has an ID that survives change (@dataclass(eq=False)+__eq__/__hash__on the ID). Wrap primitives that carry rules —Money(Decimal("62.00"), "INR")is exact, non-negative, currency-safe and self-formatting where afloatis none of those. Push every rule to the constructor and the rest of your program stops checking.
This closes the four-part OOP arc: classes and objects gave you the mechanism, inheritance and the MRO gave you the chain, encapsulation and properties gave you the boundary — and this lesson gave you the judgement to use all three sparingly. The best domain model you write this year will be mostly frozen dataclasses, a handful of dunders, and almost no inheritance at all.