Dunder Methods - Python's Protocol System at Engineering Depth
Reading time: ~35 minutes | Level: Intermediate → Engineering
Before reading further, predict every output:
class Money:
def __init__(self, amount, currency="USD"):
self.amount = amount
self.currency = currency
a = Money(10)
b = Money(10)
print(a == b) # ?
print(a > b) # ?
items = [Money(30), Money(10), Money(20)]
print(sorted(items)) # ?
d = {a: "ten dollars"}
print(d[b]) # ?
Results:
a == b→False(default==compares identity, not value)a > b→TypeError: '>' not supported between instances of 'Money' and 'Money'sorted(items)→TypeErrorfor the same reasond[b]→KeyError-bis not the same key asa, because the default hash is based onid()
Now consider: sorted(), ==, >, and dictionary key lookup all call specific dunder methods. Every Python operator, keyword, and built-in function maps to a method call on an object. That mapping is the protocol system.
Once you define the right dunders, the same code works:
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, amount, currency="USD"):
self.amount = amount
self.currency = currency
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount < other.amount
def __hash__(self):
return hash((self.amount, self.currency))
a = Money(10)
b = Money(10)
print(a == b) # True
print(a > b) # False
print(sorted([Money(30), Money(10), Money(20)])) # [Money(10), Money(20), Money(30)]
d = {a: "ten dollars"}
print(d[b]) # "ten dollars"
Understanding exactly which dunders to implement and what each one must return is the subject of this lesson.
What You Will Learn
- What dunders are and why Python's protocol system works this way
- Comparison dunders:
__eq__,__lt__,__le__,__hash__, andfunctools.total_ordering - The
NotImplementedsentinel - what it is and when to return it - Arithmetic dunders:
__add__,__radd__,__iadd__- and how Python selects which to call - Container dunders:
__len__,__getitem__,__setitem__,__delitem__,__contains__,__iter__,__next__ - Context manager dunders:
__enter__and__exit__ - Callable objects:
__call__ - Attribute access dunders:
__getattr__,__getattribute__,__setattr__,__delattr__
Prerequisites
- Lesson 01: Classes and Objects - attribute resolution, instance vs class namespace
- Lesson 02:
__init__and Object Construction - two-phase creation,super() - Comfortable reading Python with
*args,**kwargs, and type hints
Part 1 - What Dunders Are and Why They Exist
The Protocol System
A "dunder" (double underscore) method - also called a magic method or special method - is any method whose name starts and ends with two underscores. Python's interpreter calls these methods in response to syntax and built-in functions, not direct calls.
# When you write this:
a + b
# Python calls this:
type(a).__add__(a, b)
# When you write this:
len(x)
# Python calls this:
type(x).__len__(x)
# When you write this:
x[key]
# Python calls this:
type(x).__getitem__(x, key)
# When you write this:
with x as y:
# Python calls this:
y = type(x).__enter__(x)
# ... block executes ...
type(x).__exit__(x, exc_type, exc_val, exc_tb)
This is the protocol system: a set of agreed-upon method names that Python's runtime looks up on objects to implement language features. You do not call dunders directly (the one exception is super().__init__()). You implement them, and Python calls them for you.
The dispatch chain for arithmetic operators is particularly important to understand, because it involves a fallback mechanism:
This dispatch also applies to comparison operators: a == b tries a.__eq__(b), and if that returns NotImplemented, tries b.__eq__(a).
Why Not Just Use Regular Methods?
You could write a.add(b) instead of a + b. The dunders exist for two reasons:
- Syntax integration:
a + breads naturally;a.add(b)is noisy. Dunders let custom objects participate in Python's syntax. - Protocol uniformity:
len(x)works for lists, strings, dicts, and your custom class - because all of them implement__len__. This is duck typing at the language level.
The philosophy is explicit: Python does not have operator overloading hidden in the compiler. Every operator transparently calls a named method that you can inspect, override, and document.
Part 2 - Comparison Dunders
The Six Comparison Methods
# a == b → a.__eq__(b)
# a != b → a.__ne__(b) (or not a.__eq__(b) if __ne__ not defined)
# a < b → a.__lt__(b)
# a <= b → a.__le__(b)
# a > b → a.__gt__(b)
# a >= b → a.__ge__(b)
__eq__ - Equality
By default, __eq__ compares identity (is). Override it to compare by value:
class Version:
def __init__(self, major: int, minor: int, patch: int):
self.major = major
self.minor = minor
self.patch = patch
def __eq__(self, other) -> bool:
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
v1 = Version(1, 2, 3)
v2 = Version(1, 2, 3)
v3 = Version(2, 0, 0)
print(v1 == v2) # True
print(v1 == v3) # False
print(v1 == "1.2.3") # False - NotImplemented causes Python to try other.__eq__(self)
NotImplemented vs NotImplementedError
NotImplemented (the singleton, not the exception) is the return value that tells Python: "I do not know how to compare with this type - try asking the other operand."
class Celsius:
def __init__(self, degrees):
self.degrees = degrees
def __eq__(self, other):
if isinstance(other, Celsius):
return self.degrees == other.degrees
if isinstance(other, Fahrenheit):
return self.degrees == (other.degrees - 32) * 5 / 9
return NotImplemented # not False - NotImplemented
class Fahrenheit:
def __init__(self, degrees):
self.degrees = degrees
def __eq__(self, other):
if isinstance(other, Fahrenheit):
return self.degrees == other.degrees
if isinstance(other, Celsius):
return self.degrees == other.degrees * 9 / 5 + 32
return NotImplemented
c = Celsius(100)
f = Fahrenheit(212)
print(c == f) # True - cross-type comparison
When a.__eq__(b) returns NotImplemented, Python tries b.__eq__(a). If both return NotImplemented, the result falls back to identity comparison. Returning False instead of NotImplemented would prevent Python from trying the reflected operation.
:::warning NotImplemented vs NotImplementedError - Two Very Different Things
These are frequently confused, and the mistake is silent until it breaks things at runtime.
NotImplemented- a singleton object (not an exception). Return it from comparison and arithmetic dunders to tell Python "I can't handle this type combination - try the reflected method on the other operand."NotImplementedError- an exception. Raise it to signal that a method is deliberately not implemented (e.g., in abstract base classes).
# WRONG - raises NotImplementedError immediately, no reflection attempted
def __eq__(self, other):
if not isinstance(other, Money):
raise NotImplementedError # wrong!
# WRONG - shuts down reflection, Python never tries b.__eq__(a)
def __eq__(self, other):
if not isinstance(other, Money):
return False # prevents fallback
# RIGHT - allows Python to try the reflected operation
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented # Python will try other.__eq__(self)
:::
__hash__ - The Hash Contract
The contract: objects that compare equal must have the same hash. If you define __eq__, you must also define __hash__ - or your objects will be unhashable (unusable as dict keys or set members).
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
# Must be consistent with __eq__:
# if p1 == p2, then hash(p1) == hash(p2)
return hash((self.x, self.y))
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # True
print(hash(p1) == hash(p2)) # True - consistent
# Usable as dict keys and set members
cache = {p1: "cached result"}
print(cache[p2]) # "cached result" - works because equal hash and ==
points = {p1, p2}
print(len(points)) # 1 - p1 and p2 are considered the same
When you define __eq__ without __hash__, Python sets __hash__ = None, making instances unhashable. This is intentional - Python forces you to think about consistency.
class BadPoint:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# __hash__ not defined → Python sets __hash__ = None
p = BadPoint(1, 2)
hash(p) # TypeError: unhashable type: 'BadPoint'
For mutable objects: generally do not define __hash__. Mutable objects should not be dict keys because their hash could change if their fields change, corrupting the dict.
:::danger Never Use Mutable Objects as Dict Keys
If you define __eq__ and __hash__ on a mutable class, and then mutate an instance that is used as a dictionary key, the hash changes but the dict does not know - the key becomes unreachable.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y)) # hash depends on x, y
p = Point(1, 2)
d = {p: "treasure"}
print(d[p]) # "treasure" - works
# Now mutate p AFTER it is stored as a key
p.x = 99 # hash changes from hash((1,2)) to hash((99,2))
print(d[p]) # KeyError - the key is "lost" in the wrong hash bucket!
Rule: if an object is hashable (__hash__ defined), it must be effectively immutable for the lifetime of its use as a dict key or set member. Use frozenset, tuple, or @dataclass(frozen=True) for hashable value types.
:::
functools.total_ordering - Deriving All Comparisons from Two
If you define __eq__ and one of __lt__, __le__, __gt__, or __ge__, the @total_ordering decorator derives the remaining three:
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor, patch):
self.major = major
self.minor = minor
self.patch = patch
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
def __hash__(self):
return hash((self.major, self.minor, self.patch))
def __repr__(self):
return f"Version({self.major}, {self.minor}, {self.patch})"
v1 = Version(1, 2, 3)
v2 = Version(2, 0, 0)
v3 = Version(1, 2, 3)
print(v1 < v2) # True - from __lt__
print(v1 > v2) # False - derived by total_ordering
print(v1 <= v3) # True - derived
print(v1 >= v3) # True - derived
versions = [Version(2, 1, 0), Version(1, 0, 0), Version(2, 0, 5)]
print(sorted(versions))
# [Version(1, 0, 0), Version(2, 0, 5), Version(2, 1, 0)]
:::tip Use functools.total_ordering for Clean Ordering Implementation
Implementing all six comparison methods manually is tedious and error-prone. @total_ordering lets you define just __eq__ and one ordering method (__lt__ is the most natural), and it derives the rest.
from functools import total_ordering
@total_ordering
class Money:
def __eq__(self, other):
if not isinstance(other, Money): return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other):
if not isinstance(other, Money): return NotImplemented
return self.amount < other.amount
def __hash__(self):
return hash((self.amount, self.currency))
This gives you >, >=, <=, and != for free. The only tradeoff is a small performance cost vs. implementing all six manually - negligible for most application code.
:::
total_ordering has a slight performance cost vs. implementing all six manually. For performance-critical hot paths (tight loops with many comparisons), implement all six. For most application code, total_ordering is the pragmatic choice.
Part 3 - Arithmetic Dunders
Forward, Reflected, and In-Place
Python arithmetic uses three layers of dunders:
a + b:
1. Try type(a).__add__(a, b)
2. If NotImplemented, try type(b).__radd__(b, a)
3. If NotImplemented, raise TypeError
a += b:
1. Try type(a).__iadd__(a, b)
2. If NotImplemented or not defined, fall back to a = a + b (uses __add__)
class Vector:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
# Forward: Vector + Vector or Vector + scalar
def __add__(self, other):
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
if isinstance(other, (int, float)):
return Vector(self.x + other, self.y + other)
return NotImplemented
# Reflected: scalar + Vector (e.g., 5 + Vector(1, 2))
# Python calls __radd__ on Vector when int.__add__(5, Vector) returns NotImplemented
def __radd__(self, other):
return self.__add__(other) # addition is commutative here
# In-place: v += other - mutates self, returns self
def __iadd__(self, other):
if isinstance(other, Vector):
self.x += other.x
self.y += other.y
return self # MUST return self (or a new object)
if isinstance(other, (int, float)):
self.x += other
self.y += other
return self
return NotImplemented
# Subtraction
def __sub__(self, other):
if isinstance(other, Vector):
return Vector(self.x - other.x, self.y - other.y)
return NotImplemented
def __rsub__(self, other):
# other - self
if isinstance(other, (int, float)):
return Vector(other - self.x, other - self.y)
return NotImplemented
# Scalar multiplication
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Vector(self.x * scalar, self.y * scalar)
return NotImplemented
def __rmul__(self, scalar):
return self.__mul__(scalar) # commutative
# Negation (unary -)
def __neg__(self):
return Vector(-self.x, -self.y)
# Absolute value
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 + 10) # Vector(11, 12)
print(10 + v1) # Vector(11, 12) - calls v1.__radd__(10)
print(3 * v1) # Vector(3, 6) - calls v1.__rmul__(3)
print(-v1) # Vector(-1, -2)
print(abs(v2)) # 5.0
v1 += v2
print(v1) # Vector(4, 6) - mutated in place
The Full Arithmetic Dunder Table
| Operator | Forward | Reflected | In-Place |
|---|---|---|---|
+ | __add__ | __radd__ | __iadd__ |
- | __sub__ | __rsub__ | __isub__ |
* | __mul__ | __rmul__ | __imul__ |
/ | __truediv__ | __rtruediv__ | __itruediv__ |
// | __floordiv__ | __rfloordiv__ | __ifloordiv__ |
% | __mod__ | __rmod__ | __imod__ |
** | __pow__ | __rpow__ | __ipow__ |
& | __and__ | __rand__ | __iand__ |
| | __or__ | __ror__ | __ior__ |
^ | __xor__ | __rxor__ | __ixor__ |
Unary - | __neg__ | - | - |
Unary + | __pos__ | - | - |
abs() | __abs__ | - | - |
Part 4 - Container Dunders
The Container Protocol
The container protocol is a set of dunders that make your object behave like a Python built-in sequence or mapping. Here is the full picture:
Building a Full Container Protocol
Implement these dunders and your object works with len(), [] indexing, in checks, and for loops:
from typing import Any, Iterator
class BoundedList:
"""A list with a maximum capacity."""
def __init__(self, capacity: int):
self._capacity = capacity
self._data: list = []
# len(obj) → obj.__len__()
def __len__(self) -> int:
return len(self._data)
# obj[key] → obj.__getitem__(key)
def __getitem__(self, key) -> Any:
return self._data[key] # supports int index and slice
# obj[key] = value → obj.__setitem__(key, value)
def __setitem__(self, key, value) -> None:
self._data[key] = value
# del obj[key] → obj.__delitem__(key)
def __delitem__(self, key) -> None:
del self._data[key]
# value in obj → obj.__contains__(value)
def __contains__(self, value) -> bool:
return value in self._data
# for item in obj → obj.__iter__()
def __iter__(self) -> Iterator:
return iter(self._data)
# append with capacity check
def append(self, item) -> None:
if len(self._data) >= self._capacity:
raise OverflowError(
f"BoundedList at capacity ({self._capacity})"
)
self._data.append(item)
def __repr__(self):
return f"BoundedList(capacity={self._capacity}, data={self._data!r})"
bl = BoundedList(3)
bl.append("a")
bl.append("b")
bl.append("c")
print(len(bl)) # 3
print(bl[0]) # 'a'
print(bl[-1]) # 'c'
print(bl[1:]) # ['b', 'c'] - slice works because __getitem__ delegates to list
print("b" in bl) # True
print("z" in bl) # False
for item in bl: # works because __iter__
print(item)
bl[0] = "A"
print(bl[0]) # 'A'
del bl[1]
print(bl) # BoundedList(capacity=3, data=['A', 'c'])
try:
bl.append("x")
bl.append("y") # OverflowError
except OverflowError as e:
print(e) # BoundedList at capacity (3)
__iter__ and __next__ - Making an Iterator
__iter__ returns an iterator object. The simplest case: return iter(self._data) to delegate to the list's iterator (as above). But you can implement a custom iterator by making the object its own iterator:
class CountUp:
"""An iterator that counts from start to stop (inclusive)."""
def __init__(self, start: int, stop: int):
self.start = start
self.stop = stop
self._current = start
def __iter__(self):
return self # this object IS the iterator
def __next__(self):
if self._current > self.stop:
raise StopIteration
value = self._current
self._current += 1
return value
for n in CountUp(1, 5):
print(n) # 1, 2, 3, 4, 5
# Iterators are exhausted after one pass
counter = CountUp(1, 3)
print(list(counter)) # [1, 2, 3]
print(list(counter)) # [] - exhausted
The distinction between iterable (has __iter__) and iterator (has both __iter__ and __next__) matters:
- An iterable can be iterated multiple times (e.g., a list)
- An iterator is stateful and exhausts after one pass
If you want a reusable iterable, make __iter__ return a new iterator object each time:
class NumberRange:
"""Reusable iterable - creates a fresh iterator on each for loop."""
def __init__(self, start, stop):
self.start = start
self.stop = stop
def __iter__(self):
# return a new CountUp iterator each time
return CountUp(self.start, self.stop)
def __len__(self):
return max(0, self.stop - self.start + 1)
r = NumberRange(1, 5)
print(list(r)) # [1, 2, 3, 4, 5]
print(list(r)) # [1, 2, 3, 4, 5] - still works, fresh iterator
__bool__ and __len__ - Truth Testing
When Python evaluates if obj: or bool(obj):
- It calls
__bool__if defined - Otherwise calls
__len__- empty (0) means falsy - Otherwise the object is truthy by default
class QueryResult:
def __init__(self, rows):
self._rows = rows
def __len__(self):
return len(self._rows)
def __bool__(self):
return len(self._rows) > 0
result = QueryResult([])
if result:
print("has data")
else:
print("empty") # empty
result2 = QueryResult(["row1", "row2"])
if result2:
print("has data") # has data
Part 5 - Context Manager Dunders
__enter__ and __exit__
The with statement calls __enter__ on entry and __exit__ on exit (even if an exception occurs):
class ManagedConnection:
"""Simulates a database connection with guaranteed cleanup."""
def __init__(self, host: str, port: int = 5432):
self.host = host
self.port = port
self._conn = None
def __enter__(self):
print(f"Connecting to {self.host}:{self.port}")
self._conn = {"host": self.host, "active": True} # simulate connection
return self # the value bound to 'as' variable
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"Closing connection to {self.host}:{self.port}")
if self._conn:
self._conn["active"] = False
self._conn = None
if exc_type is not None:
print(f"Exception during connection: {exc_type.__name__}: {exc_val}")
# Return True to suppress the exception
# Return False (or None) to let it propagate
return False # re-raise
def query(self, sql: str):
if not self._conn:
raise RuntimeError("Not connected")
return f"Results of: {sql}"
# Normal usage
with ManagedConnection("db.example.com") as conn:
print(conn.query("SELECT 1"))
# Connecting to db.example.com:5432
# Results of: SELECT 1
# Closing connection to db.example.com:5432
# Exception handling
try:
with ManagedConnection("db.example.com") as conn:
print(conn.query("SELECT 1"))
raise ValueError("something went wrong")
except ValueError:
pass
# Closing connection is still called - guaranteed cleanup
The __exit__ Signature
__exit__(self, exc_type, exc_val, exc_tb):
exc_type: the exception class, orNoneif no exceptionexc_val: the exception instance, orNoneexc_tb: the traceback object, orNone- Return
Trueto suppress the exception; returnFalseorNoneto let it propagate
class SuppressErrors:
"""Context manager that silently ignores specific exception types."""
def __init__(self, *exception_types):
self.exception_types = exception_types
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None and issubclass(exc_type, self.exception_types):
return True # suppress
return False # propagate
with SuppressErrors(ValueError, KeyError):
raise ValueError("ignored") # suppressed
print("never reached")
print("execution continues") # "execution continues"
# compare to contextlib.suppress - same idea, stdlib implementation
from contextlib import suppress
with suppress(ValueError):
raise ValueError("also ignored")
Implementing a Timer Context Manager
import time
class Timer:
def __init__(self, label: str = ""):
self.label = label
self.elapsed: float = 0.0
def __enter__(self):
self._start = time.perf_counter()
return self # caller can inspect elapsed after the block
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self._start
label = f"[{self.label}] " if self.label else ""
print(f"{label}elapsed: {self.elapsed:.4f}s")
return False # do not suppress exceptions
with Timer("matrix multiply") as t:
result = sum(i * i for i in range(1_000_000))
print(f"result={result}, time={t.elapsed:.4f}s")
Part 6 - Callable Objects
__call__
Any object that defines __call__ can be called with parentheses, making it behave like a function:
class Multiplier:
def __init__(self, factor: float):
self.factor = factor
def __call__(self, value: float) -> float:
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # 10.0
print(triple(7)) # 21.0
print(callable(double)) # True
# Works anywhere a function is expected
numbers = [1, 2, 3, 4, 5]
print(list(map(double, numbers))) # [2.0, 4.0, 6.0, 8.0, 10.0]
__call__ is useful for stateful callables - objects that behave like functions but carry state:
class RateLimiter:
"""Callable that raises if called more than N times per second."""
import time as _time
def __init__(self, max_calls: int, period: float = 1.0):
self.max_calls = max_calls
self.period = period
self._calls: list = []
def __call__(self, func, *args, **kwargs):
import time
now = time.monotonic()
# Remove calls outside the window
self._calls = [t for t in self._calls if now - t < self.period]
if len(self._calls) >= self.max_calls:
raise RuntimeError(
f"Rate limit exceeded: {self.max_calls} calls per {self.period}s"
)
self._calls.append(now)
return func(*args, **kwargs)
def __repr__(self):
return f"RateLimiter(max_calls={self.max_calls}, period={self.period})"
limiter = RateLimiter(max_calls=3, period=1.0)
print(repr(limiter)) # RateLimiter(max_calls=3, period=1.0)
The difference between a class with __call__ and a closure is that the class version is inspectable, testable, and subclassable.
Part 7 - Attribute Access Dunders
__getattr__ vs __getattribute__
These are the two hooks for attribute access. The difference is critical:
__getattribute__: called on every attribute access, including existing ones__getattr__: called only when normal attribute lookup fails (the attribute does not exist in__dict__or the class)
class Logged:
def __getattribute__(self, name):
# Called on EVERY attribute access - even __dict__, __class__, etc.
print(f" accessing: {name!r}")
return super().__getattribute__(name) # MUST delegate to super
def __init__(self, value):
self.value = value
l = Logged(42)
# accessing: '__init__' is printed during __init__ setup... but more visibly:
x = l.value # prints: accessing: 'value'
__getattribute__ is rarely overridden in application code - it runs on every access including internal Python machinery, and an infinite recursion is easy to trigger if you forget super(). It is used in proxy objects, ORMs, and attribute logging frameworks.
__getattr__ is much more common:
class FlexRecord:
"""Allows attribute-style access to arbitrary key-value pairs."""
def __init__(self, **kwargs):
# Store in __dict__ directly to avoid triggering our __setattr__
object.__setattr__(self, "_data", {})
for key, value in kwargs.items():
self._data[key] = value
def __getattr__(self, name):
# Only called when normal lookup fails (name not in __dict__ or class)
try:
return self._data[name]
except KeyError:
raise AttributeError(f"{type(self).__name__!r} has no attribute {name!r}")
def __setattr__(self, name, value):
if name.startswith("_"):
object.__setattr__(self, name, value)
else:
self._data[name] = value
def __repr__(self):
return f"FlexRecord(**{self._data!r})"
r = FlexRecord(name="Alice", age=30, city="Portland")
print(r.name) # Alice
print(r.age) # 30
r.score = 98
print(r.score) # 98
print(r) # FlexRecord(**{'name': 'Alice', 'age': 30, 'city': 'Portland', 'score': 98})
try:
print(r.missing)
except AttributeError as e:
print(e) # 'FlexRecord' has no attribute 'missing'
__setattr__ and __delattr__
__setattr__ is called on every attribute assignment: self.x = value calls type(self).__setattr__(self, "x", value). The default implementation writes to self.__dict__. Override to intercept assignments:
class Validated:
"""All assignments go through validation."""
_validators = {} # class-level: field name → validator function
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls._validators = {}
def __setattr__(self, name, value):
validator = self._validators.get(name)
if validator is not None:
validator(name, value)
super().__setattr__(name, value) # MUST delegate to super
class Config(Validated):
_validators = {
"port": lambda n, v: (_ for _ in ()).throw(ValueError(f"{n} must be 1-65535"))
if not (1 <= v <= 65535) else None,
"workers": lambda n, v: (_ for _ in ()).throw(ValueError(f"{n} must be positive"))
if v <= 0 else None,
}
def __init__(self, port, workers):
self.port = port # triggers __setattr__ → validation
self.workers = workers # triggers __setattr__ → validation
For cleaner validation, use @property (Lesson 05) or descriptors rather than overriding __setattr__ globally. __setattr__ is most useful when you need to intercept all assignments uniformly - for change tracking, audit logging, or reactive programming.
A Practical Example: Change-Tracking Object
class Tracked:
"""Records which attributes have been modified since creation."""
def __init__(self, **kwargs):
object.__setattr__(self, "_original", {})
object.__setattr__(self, "_changed", set())
for key, value in kwargs.items():
object.__setattr__(self, key, value)
self._original[key] = value
def __setattr__(self, name, value):
if not name.startswith("_"):
if name in self._original and self._original[name] != value:
self._changed.add(name)
object.__setattr__(self, name, value)
@property
def is_dirty(self) -> bool:
return len(self._changed) > 0
@property
def changed_fields(self) -> set:
return frozenset(self._changed)
def mark_clean(self):
for field in self._changed:
self._original[field] = getattr(self, field)
self._changed.clear()
def __repr__(self):
return f"Tracked(changed={self._changed}, ...)"
print(user.is_dirty) # False
user.age = 31
print(user.is_dirty) # True
print(user.changed_fields) # frozenset({'age', 'email'})
user.mark_clean()
print(user.is_dirty) # False
This pattern is used in Django's ORM (.save() only updates changed fields), SQLAlchemy's unit-of-work, and any system that needs to track dirty state efficiently.
Part 8 - Putting It Together: A Production-Quality Example
from functools import total_ordering
from typing import Optional, Iterator
@total_ordering
class PriorityQueue:
"""A bounded priority queue with full container and comparison protocols."""
def __init__(self, capacity: int = 100):
if capacity <= 0:
raise ValueError("capacity must be positive")
self._capacity = capacity
self._data: list = []
# Container protocol
def __len__(self) -> int:
return len(self._data)
def __iter__(self) -> Iterator:
# iterate in priority order without modifying internal state
return iter(sorted(self._data))
def __contains__(self, item) -> bool:
return item in self._data
def __getitem__(self, index):
return sorted(self._data)[index]
def __bool__(self) -> bool:
return len(self._data) > 0
# Comparison protocol - compare queues by their contents
def __eq__(self, other) -> bool:
if not isinstance(other, PriorityQueue):
return NotImplemented
return sorted(self._data) == sorted(other._data)
def __lt__(self, other) -> bool:
if not isinstance(other, PriorityQueue):
return NotImplemented
return len(self._data) < len(other._data)
# Callable - calling the queue pushes an item
def __call__(self, item) -> "PriorityQueue":
self.push(item)
return self # fluent interface
# Context manager - guaranteed cleanup
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._data.clear()
return False
def push(self, item) -> None:
if len(self._data) >= self._capacity:
raise OverflowError(f"Queue at capacity ({self._capacity})")
self._data.append(item)
def pop(self):
if not self._data:
raise IndexError("pop from empty queue")
return min(self._data, key=lambda x: x)
def __repr__(self) -> str:
return f"PriorityQueue(capacity={self._capacity}, size={len(self)})"
# Usage
pq = PriorityQueue(capacity=5)
pq(3)(1)(4)(1)(5) # fluent: each call returns self
print(len(pq)) # 5
print(1 in pq) # True
print(list(pq)) # [1, 1, 3, 4, 5] - sorted
with PriorityQueue(10) as temp:
temp.push(7)
temp.push(2)
print(list(temp)) # [2, 7]
# temp._data is cleared on exit
print(len(temp)) # 0
Common Mistakes
Mistake 1 - Returning False Instead of NotImplemented
# Wrong - prevents Python from trying the reflected operation
class A:
def __eq__(self, other):
if not isinstance(other, A):
return False # shuts down the reflection chain
# Right
class A:
def __eq__(self, other):
if not isinstance(other, A):
return NotImplemented # Python will try other.__eq__(self)
Mistake 2 - Defining __eq__ Without __hash__
# Wrong - instances become unhashable (can't use as dict keys or in sets)
class Point:
def __eq__(self, other):
return self.x == other.x # __hash__ is implicitly set to None
# Right
class Point:
def __eq__(self, other):
if not isinstance(other, Point): return NotImplemented
return self.x == other.x
def __hash__(self):
return hash(self.x) # consistent with __eq__
Mistake 3 - Infinite Recursion in __getattribute__
# Wrong - accessing self.data inside __getattribute__ calls __getattribute__ again
class Bad:
def __getattribute__(self, name):
log(self.data) # INFINITE RECURSION - self.data calls __getattribute__
return super().__getattribute__(name)
# Right - use super().__getattribute__ to access own attributes
class Good:
def __getattribute__(self, name):
data = super().__getattribute__("data") # safe access
log(data)
return super().__getattribute__(name)
Mistake 4 - __iadd__ Forgetting to Return self
# Wrong - augmented assignment silently becomes None
class Counter:
def __iadd__(self, other):
self.count += other
# no return - returns None
c = Counter()
c += 1 # c is now None!
# Right
class Counter:
def __iadd__(self, other):
self.count += other
return self # always return self from __iadd__
Mistake 5 - __setattr__ Causing Infinite Recursion
# Wrong - self.x = value inside __setattr__ calls __setattr__ again
class Bad:
def __setattr__(self, name, value):
self.name = value # infinite recursion
# Right - delegate to object.__setattr__ or write to __dict__ directly
class Good:
def __setattr__(self, name, value):
object.__setattr__(self, name, value) # bypasses our override
# or: self.__dict__[name] = value - but object.__setattr__ is safer
Engineering Checklist
Before moving to the next lesson, verify you can answer these without looking:
- What does Python call when you write
a + b? What is the reflected operation called if the first returnsNotImplemented? - What is the difference between returning
NotImplementedand returningFalsefrom__eq__? - If you define
__eq__, what else must you define, and why? - What does
@total_orderingrequire from you, and what does it derive? - What is the difference between
__getattr__and__getattribute__? When is each called? - What arguments does
__exit__receive? How do you suppress an exception inside awithblock? - What must
__iadd__return? - What is the iterable vs iterator distinction, and which dunders define each?
- What happens in
bool(obj)when__bool__is not defined?
Quick Reference
# Comparison
def __eq__(self, other):
if not isinstance(other, MyClass): return NotImplemented
return self.value == other.value
def __lt__(self, other):
if not isinstance(other, MyClass): return NotImplemented
return self.value < other.value
def __hash__(self):
return hash(self.value) # must be consistent with __eq__
# Arithmetic
def __add__(self, other): ... # self + other
def __radd__(self, other): ... # other + self (reflected)
def __iadd__(self, other): # self += other
...
return self # must return self
# Container
def __len__(self): return len(self._data)
def __getitem__(self, key): return self._data[key]
def __setitem__(self, key, value): self._data[key] = value
def __contains__(self, item): return item in self._data
def __iter__(self): return iter(self._data)
# Context manager
def __enter__(self): return self
def __exit__(self, exc_type, exc_val, exc_tb): return False # don't suppress
# Callable
def __call__(self, *args, **kwargs): ...
# Attribute access
def __getattr__(self, name): ... # only called when lookup fails
def __setattr__(self, name, value):
object.__setattr__(self, name, value) # always delegate to super
Graded Practice Challenges
Level 1 - Predict the Output
Question 1: What does this print?
class Money:
def __init__(self, amount, currency="USD"):
self.amount = amount
self.currency = currency
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __hash__(self):
return hash((self.amount, self.currency))
a = Money(10, "USD")
b = Money(10, "USD")
c = Money(10, "EUR")
print(a == b)
print(a == c)
print(hash(a) == hash(b))
Show Answer
Output:
True
False
True
a == b is True because both amount and currency match. a == c is False because the currencies differ even though amounts match. hash(a) == hash(b) is True because equal objects must have the same hash - hash((10, "USD")) produces the same value for both.
Question 2: What does this print?
class Box:
def __init__(self, items):
self._items = list(items)
def __len__(self):
return len(self._items)
def __bool__(self):
return len(self._items) > 0
empty = Box([])
full = Box([1, 2, 3])
print(bool(empty))
print(bool(full))
print(len(empty))
if full:
print("has items")
Show Answer
Output:
False
True
0
has items
bool(empty) calls __bool__, which returns len(self._items) > 0 → 0 > 0 → False. bool(full) → 3 > 0 → True. len(empty) calls __len__ → 0. The if full: check calls __bool__ implicitly → True → prints "has items".
Question 3: What does this print?
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
if isinstance(other, (int, float)):
return Vector(self.x + other, self.y + other)
return NotImplemented
def __radd__(self, other):
return self.__add__(other)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v = Vector(1, 2)
print(v + 5)
print(5 + v)
Show Answer
Output:
Vector(6, 7)
Vector(6, 7)
v + 5 calls Vector.__add__(v, 5) → returns Vector(6, 7). 5 + v first tries int.__add__(5, v) → returns NotImplemented (int does not know about Vector). Python then tries Vector.__radd__(v, 5) → delegates to self.__add__(5) → Vector(6, 7).
Question 4: What does this print?
class Counter:
def __init__(self, count=0):
self.count = count
def __iadd__(self, other):
self.count += other
return self
c = Counter(10)
original_id = id(c)
c += 5
print(c.count)
print(id(c) == original_id)
Show Answer
Output:
15
True
c += 5 calls Counter.__iadd__(c, 5). The method mutates self.count in place and returns self. Because __iadd__ returns self (the same object), c remains bound to the original object - id(c) is unchanged. If __iadd__ had returned a new object or forgot to return self, c would be bound to that instead.
Question 5: What does this print?
class Safe:
def __enter__(self):
print("entering")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"exiting, exc_type={exc_type}")
return True # suppress exceptions
with Safe() as s:
print("inside")
raise ValueError("oops")
print("after with")
Show Answer
Output:
entering
inside
exiting, exc_type=<class 'ValueError'>
after with
__enter__ prints "entering" and returns self. The with block executes, prints "inside", then raises ValueError. Python calls __exit__ with the exception details. __exit__ prints the exception type and returns True, which suppresses the exception. Execution continues normally after the with block - "after with" is printed.
Level 2 - Debug Challenge
Find and fix all bugs:
from functools import total_ordering
@total_ordering
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __eq__(self, other):
if not isinstance(other, Temperature):
return False # bug 1
return self.celsius == other.celsius
def __lt__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius < other.celsius
# bug 2: no __hash__ defined
def __iadd__(self, degrees):
self.celsius += degrees
# bug 3: missing return
def __repr__(self):
return f"Temperature({self.celsius})"
t1 = Temperature(100)
t2 = Temperature(100)
t3 = Temperature(50)
print(t1 == t2)
temps = {t1: "boiling"}
print(temps[t2]) # should work since t1 == t2
t3 += 50
print(t3) # should be Temperature(100)
print(t3 == t1)
Show Solution
Bugs:
-
return Falsein__eq__- should bereturn NotImplemented. ReturningFalseshort-circuits Python's reflection mechanism;NotImplementedlets Python tryother.__eq__(self). -
No
__hash__defined - when you define__eq__, Python implicitly sets__hash__ = None, makingTemperatureunhashable.temps = {t1: "boiling"}raisesTypeError: unhashable type: 'Temperature'. -
__iadd__has no return statement - returnsNone. Aftert3 += 50, the namet3is rebound toNone, losing the object. Every subsequent use oft3raisesAttributeError.
Fixed version:
from functools import total_ordering
@total_ordering
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __eq__(self, other):
if not isinstance(other, Temperature):
return NotImplemented # fix 1: use NotImplemented, not False
return self.celsius == other.celsius
def __lt__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius < other.celsius
def __hash__(self): # fix 2: add __hash__ consistent with __eq__
return hash(self.celsius)
def __iadd__(self, degrees):
self.celsius += degrees
return self # fix 3: always return self from __iadd__
def __repr__(self):
return f"Temperature({self.celsius})"
t1 = Temperature(100)
t2 = Temperature(100)
t3 = Temperature(50)
print(t1 == t2) # True
temps = {t1: "boiling"}
print(temps[t2]) # "boiling" - works now
t3 += 50
print(t3) # Temperature(100)
print(t3 == t1) # True
Level 3 - Design Challenge
Design a production-quality Money class that supports:
- Value equality: two
Moneyobjects are equal if they have the same amount and currency - Ordering: comparison by amount (only valid for same currency)
- Arithmetic:
Money + Money(same currency),Money * scalar,scalar * Money - String representation:
"$10.00"for USD,"€5.50"for EUR - Usable as a dict key (hashable)
- A
__bool__that returnsFalsefor zero-amount money
Show Reference Solution
from functools import total_ordering
from decimal import Decimal
CURRENCY_SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥"}
@total_ordering
class Money:
"""An immutable monetary value with currency."""
def __init__(self, amount, currency: str = "USD"):
# Use Decimal for exact arithmetic - avoid float rounding issues
self.amount = Decimal(str(amount))
self.currency = currency.upper()
# Requirement 1 & 5: equality and hashability
def __eq__(self, other) -> bool:
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __hash__(self) -> int:
return hash((self.amount, self.currency))
# Requirement 2: ordering (same currency only)
def __lt__(self, other) -> bool:
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(
f"Cannot compare {self.currency} with {other.currency}"
)
return self.amount < other.amount
# Requirement 3: arithmetic
def __add__(self, other) -> "Money":
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(
f"Cannot add {self.currency} and {other.currency}"
)
return Money(self.amount + other.amount, self.currency)
def __radd__(self, other) -> "Money":
# Supports sum() - sum() starts with 0, so 0 + Money must work
if other == 0:
return self
return self.__add__(other)
def __mul__(self, scalar) -> "Money":
if isinstance(scalar, (int, float, Decimal)):
return Money(self.amount * Decimal(str(scalar)), self.currency)
return NotImplemented
def __rmul__(self, scalar) -> "Money":
return self.__mul__(scalar)
def __neg__(self) -> "Money":
return Money(-self.amount, self.currency)
# Requirement 6: falsy when zero
def __bool__(self) -> bool:
return self.amount != 0
# Requirement 4: string representation
def __str__(self) -> str:
symbol = CURRENCY_SYMBOLS.get(self.currency, self.currency)
return f"{symbol}{self.amount:.2f}"
def __repr__(self) -> str:
return f"Money({self.amount!r}, {self.currency!r})"
# Demonstrate all requirements
a = Money(10, "USD")
b = Money(10, "USD")
c = Money(5, "USD")
d = Money(10, "EUR")
# Requirement 1: value equality
print(a == b) # True
print(a == d) # False - different currency
# Requirement 5: hashable
cache = {a: "ten dollars"}
print(cache[b]) # "ten dollars" - b finds a's key because they're equal
# Requirement 2: ordering
print(a > c) # True
print(sorted([Money(30), Money(10), Money(20)])) # sorted by amount
# Requirement 3: arithmetic
print(a + c) # Money('15.0', 'USD')
print(a * 1.5) # Money('15.0', 'USD')
print(3 * c) # Money('15.0', 'USD')
print(sum([a, b, c])) # Money('25.0', 'USD') - radd handles 0 + Money
# Requirement 4: string representation
print(str(a)) # $10.00
print(str(d)) # €10.00
# Requirement 6: falsiness
print(bool(Money(0))) # False
print(bool(Money(0.01))) # True
Design decisions:
Decimalavoids floating-point rounding errors (critical for money)__radd__handlessum()which starts from0-0 + Moneymust returnMoney- Cross-currency operations raise
ValueError- silent wrong results are worse than loud errors __neg__allows representing debts as negative money
Key Takeaways
- Every Python operator, built-in function, and keyword maps to a dunder method call on the object - this is the protocol system
- Arithmetic dispatch:
a + btriesa.__add__(b)first, thenb.__radd__(a)if the first returnsNotImplemented NotImplemented(singleton) tells Python to try the reflected operation - never returnFalsewhereNotImplementedis requiredNotImplementedError(exception) is completely different - it signals an unimplemented abstract method- Defining
__eq__without__hash__makes instances unhashable - always define both together - The hash contract: equal objects must have equal hashes - never violate this
- Mutable objects should generally not implement
__hash__- using them as dict keys is a correctness trap @total_orderingderives all six comparison methods from just__eq__+ one ordering method__iadd__(and all in-place operators) must returnself- forgetting this silently binds the variable toNone__getattr__is called only when lookup fails;__getattribute__is called on every access - override__getattr__in most cases- Any class with
__enter__and__exit__works withwithstatements - use this for guaranteed cleanup
What's Next
Lesson 04 covers __repr__, __str__, and __format__ - Python's representation and string protocol. These are the dunders every object should define, and most developers implement them incorrectly or incompletely.
You will learn when Python calls each, the rule that repr() should return an eval-able string, how to implement custom format specifications in f-strings with __format__, the !r, !s, and !a conversion flags, and how good __repr__ makes debugging and logging dramatically easier in production systems.
