Skip to main content

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 == bFalse (default == compares identity, not value)
  • a > bTypeError: '>' not supported between instances of 'Money' and 'Money'
  • sorted(items)TypeError for the same reason
  • d[b]KeyError - b is not the same key as a, because the default hash is based on id()

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__, and functools.total_ordering
  • The NotImplemented sentinel - 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:

  1. Syntax integration: a + b reads naturally; a.add(b) is noisy. Dunders let custom objects participate in Python's syntax.
  2. 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

OperatorForwardReflectedIn-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):

  1. It calls __bool__ if defined
  2. Otherwise calls __len__ - empty (0) means falsy
  3. 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, or None if no exception
  • exc_val: the exception instance, or None
  • exc_tb: the traceback object, or None
  • Return True to suppress the exception; return False or None to 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}, ...)"

user = Tracked(name="Alice", email="[email protected]", age=30)
print(user.is_dirty) # False

user.age = 31
user.email = "[email protected]"
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:

  1. What does Python call when you write a + b? What is the reflected operation called if the first returns NotImplemented?
  2. What is the difference between returning NotImplemented and returning False from __eq__?
  3. If you define __eq__, what else must you define, and why?
  4. What does @total_ordering require from you, and what does it derive?
  5. What is the difference between __getattr__ and __getattribute__? When is each called?
  6. What arguments does __exit__ receive? How do you suppress an exception inside a with block?
  7. What must __iadd__ return?
  8. What is the iterable vs iterator distinction, and which dunders define each?
  9. 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) > 00 > 0False. bool(full)3 > 0True. 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:

  1. return False in __eq__ - should be return NotImplemented. Returning False short-circuits Python's reflection mechanism; NotImplemented lets Python try other.__eq__(self).

  2. No __hash__ defined - when you define __eq__, Python implicitly sets __hash__ = None, making Temperature unhashable. temps = {t1: "boiling"} raises TypeError: unhashable type: 'Temperature'.

  3. __iadd__ has no return statement - returns None. After t3 += 50, the name t3 is rebound to None, losing the object. Every subsequent use of t3 raises AttributeError.

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:

  1. Value equality: two Money objects are equal if they have the same amount and currency
  2. Ordering: comparison by amount (only valid for same currency)
  3. Arithmetic: Money + Money (same currency), Money * scalar, scalar * Money
  4. String representation: "$10.00" for USD, "€5.50" for EUR
  5. Usable as a dict key (hashable)
  6. A __bool__ that returns False for 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:

  • Decimal avoids floating-point rounding errors (critical for money)
  • __radd__ handles sum() which starts from 0 - 0 + Money must return Money
  • 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 + b tries a.__add__(b) first, then b.__radd__(a) if the first returns NotImplemented
  • NotImplemented (singleton) tells Python to try the reflected operation - never return False where NotImplemented is required
  • NotImplementedError (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_ordering derives all six comparison methods from just __eq__ + one ordering method
  • __iadd__ (and all in-place operators) must return self - forgetting this silently binds the variable to None
  • __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 with with statements - 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.

© 2026 EngineersOfAI. All rights reserved.