Dunder protocols: __repr__, the eq/hash contract, truthiness, and NotImplemented
Dunders are how objects plug into Python syntax: __repr__ for debugging, the __eq__/__hash__ contract (define eq, lose hash), the __bool__/__len__ truthiness chain, and operator dunders that return NotImplemented so the other operand gets a turn. Protocols beat inheritance.
The deduplication job had run nightly for a year. Then someone added a sensible-looking __eq__ to the Order class so tests could compare orders by id — and the next night the job crashed with TypeError: unhashable type: 'Order'. Nobody had touched the dedup code; it still did seen = set() and if order in seen. The change that broke it was three lines away in a different file: defining __eq__ without __hash__ makes Python silently set __hash__ = None, because equal objects must hash equal and Python cannot guarantee that for your custom equality. The author had run the comparison tests — all green — and shipped. The incident review’s takeaway went on the team wiki: dunder methods are not isolated conveniences, they are a contract system. Touch one clause of the data model and the interpreter holds you to the rest of the contract, sometimes in a job that runs at 3 a.m.
In ten minutes you will know exactly which dunder broke the nightly job — and why fixing it requires touching two methods, not one.
repr and str: the debugging contract
repr(x) calls x.__repr__() and targets developers: unambiguous, ideally eval-able or at least identifying (Order(id=42, total=Decimal('19.90'))). str(x) calls __str__ and targets users; if you don’t define it, str falls back to __repr__ — so define __repr__ first and __str__ only when you need a friendlier form. The default __repr__ is <Order object at 0x7f...>, which is what your logs and tracebacks show at 3 a.m. when you skipped this. Containers always use repr for their elements — print([order]) shows the repr even though you called print — which is exactly the behavior you want when reading a dumped list of 200 orders.
from decimal import Decimal
class Order:
def __init__(self, id: int, total: Decimal):
self.id, self.total = id, total
def __repr__(self) -> str:
# !r recursively uses repr of the fields — Decimal('19.90'), not 19.90
return f"Order(id={self.id!r}, total={self.total!r})"
print(repr(Order(42, Decimal("19.90")))) # Order(id=42, total=Decimal('19.90'))The eq/hash contract
The rule the interpreter enforces: if a == b then hash(a) == hash(b) must hold, because dicts and sets locate objects by hash bucket first, equality second. Default __eq__ is identity (is), default __hash__ is derived from id() — consistent. The moment you define __eq__, Python sets __hash__ = None on that class: your objects become unhashable until you supply a __hash__ that agrees with your equality (hash the same tuple of fields you compare). The trap from the Hook is the silent half of this: nothing fails at class definition time, only at the first set/dict use. And a second-order trap: hashable objects must be effectively immutable in their hashed fields — mutate order.id after inserting into a set and the object is now in the wrong bucket, present but unfindable. x in s returns False while x sits in s.
class Order:
def __init__(self, id: int):
self.id = id
def __eq__(self, other):
if not isinstance(other, Order):
return NotImplemented # not False — see below
return self.id == other.id
def __hash__(self):
return hash(self.id) # same fields as __eq__, or the contract breaksYou add __eq__ to a class comparing by id, run the equality tests (green), and ship. A nightly job doing `if obj in seen_set` now crashes with TypeError. Why?
Truthiness and length: the bool/len chain
When you write if response: on your own collection class, does Python call __eq__, __bool__, or something else entirely? The answer determines whether an empty collection is falsy — or whether every instance silently passes the check.
if x: never calls __eq__. It calls bool(x), which tries __bool__ first; if absent, it falls back to __len__ (nonzero length is truthy); if both are absent, every instance is truthy. This chain is why if response: on a custom collection does the right thing for free once you define __len__ — and why it backfires on objects where emptiness is not falseness. The classic production bite: an ORM query object or a datetime.time of midnight ending up in if value: and being treated as “no value”. If your class has a length but “empty” is still meaningful data, either define __bool__ returning True explicitly or make call sites use if value is not None:.
Operator dunders and NotImplemented
a + b runs type(a).__add__(a, b); if that returns the sentinel NotImplemented, Python tries the reflected type(b).__radd__(b, a); only if both decline does it raise TypeError. (One refinement: if type(b) is a subclass of type(a), the reflected method gets the first try, so subclasses can override behavior in mixed expressions.) NotImplemented is a return value meaning “I don’t know this operand type, ask the other side” — it is not the exception NotImplementedError, which is what abstract methods raise to mean “subclass forgot to implement me”. Returning False from __eq__ for unknown types is the subtle version of this bug: you make Order(1) == 1 definitively false instead of letting int have a say, and you break symmetric behavior for someone else’s wrapper type. This is also why protocols beat inheritance in Python: len(), in, iteration, arithmetic all dispatch on dunders, so any class implementing the methods participates — no base class required. Duck typing is dunder dispatch.
Your Money.__add__ raises NotImplementedError for unknown operand types. A teammate's Discount class defines __radd__ to handle Money + Discount. What happens to `money + discount`?
- 01State the __eq__/__hash__ contract, what Python does when you define only __eq__, and the mutation trap that survives even a correct implementation.
- 02Walk through what happens for `a + b` when the types differ, and explain why returning False or raising NotImplementedError from a dunder are both bugs.
The data model is Python’s protocol system: syntax like ==, in, if x:, len(), and + dispatches to dunder methods, so any class implementing the right methods participates — duck typing is dunder dispatch, no inheritance required. __repr__ serves developers (unambiguous, field-revealing, used by containers and tracebacks); __str__ serves users and falls back to __repr__. The eq/hash contract is the unit’s sharpest edge: equal objects must hash equal, so defining __eq__ makes Python silently set __hash__ = None, and the failure surfaces only at the first set or dict use — possibly in a nightly job far from your diff. Restore __hash__ over the same fields you compare and treat those fields as immutable, or objects get lost inside their own containers. Truthiness walks a chain — __bool__, then __len__, then default-truthy — which is convenient for collections and a trap for objects where empty is valid data. Binary operators run left __add__ first, hand off to reflected __radd__ when they return the NotImplemented sentinel (subclass right operands get priority), and raise TypeError only when both sides decline. Return NotImplemented for unknown operand types; raising NotImplementedError aborts dispatch and forecloses interop with types you have never met. Now when you see a TypeError: unhashable type surface far from your diff, or a midnight job that breaks on in set after a perfectly green test run, you know exactly which contract was touched and which clause was left unfulfilled.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.