Python Lesson 18 of 71

OOP Part 4 — Designing Domain Models: Dunder Methods, dataclasses & Composition

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.

Python data model dispatch: an expression such as repr(o), len(o), o == p or with o: is looked up by the interpreter on type(o) rather than the instance, dispatched to the dunder you implemented, and run against a composed domain model in which Order has-a list of LineItem and each LineItem has-a frozen Money value object, with invariants enforced once in post_init

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:

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__:

  1. If a == b, then hash(a) == hash(b) must hold. (The reverse is not required — unequal objects may collide.)
  2. 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 UnhashableTypeError: 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 instancelist, 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.60484 × 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 * 3186.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:


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.subtotalsuper()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


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.

pythonoopdataclassesdunder-methodsdata-modelmagic-methodscompositiondomain-modellingvalue-objectsfrozennamedtupletype-hintsdecimalprotocols
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments