Dunder Methods
What are dunder methods?
Section titled “What are dunder methods?”Dunder methods (short for double-underscore) are special methods like __repr__, __eq__, and __add__ that Python calls automatically in response to built-in syntax and functions.
They are the mechanism behind the Python Data Model — the contract that lets your objects behave like built-in types.
__repr__ and __str__ — who calls which
Section titled “__repr__ and __str__ — who calls which”Both produce string representations, but they serve different audiences:
| Method | When called | Purpose |
|---|---|---|
__repr__ | repr(obj), interactive shell, elements inside a container | Unambiguous developer representation |
__str__ | str(obj), print(obj), f-string {obj} | Human-friendly display |
If only __repr__ is defined, Python uses it for both.
The convention for __repr__ is to return a string that, if passed to eval(), would recreate the object.
class Point: def __init__(self, x: float, y: float) -> None: self.x = x self.y = y
def __repr__(self) -> str: return f"Point({self.x}, {self.y})"
def __str__(self) -> str: return f"({self.x}, {self.y})"A detail that surprises people who define both: print([Point(1, 2)]) prints [Point(1, 2)], not [(1, 2)]. A list never calls __str__ on its elements — it always calls __repr__, even though print() on the list itself uses the list’s own __str__ (which for a list is built from each element’s repr). __str__ is only reached when you convert or print the object directly.
__eq__ — equality
Section titled “__eq__ — equality”Python calls __eq__ when you write a == b.
Without it, == falls back to identity comparison (is) — two separate instances with the same data are never equal.
def __eq__(self, other: object) -> bool: if not isinstance(other, Point): return NotImplemented return self.x == other.x and self.y == other.y__eq__ sets __hash__ to None — the real TypeError, and two fixes
Section titled “__eq__ sets __hash__ to None — the real TypeError, and two fixes”Defining __eq__ on a class disables the default __hash__ it inherited from object — Python sets __hash__ = None automatically, because two objects that compare equal must hash equal, and your custom __eq__ broke that guarantee the interpreter could previously assume. The instance is now unhashable:
class User: def __init__(self, uid: int) -> None: self.uid = uid
def __eq__(self, other: object) -> bool: return isinstance(other, User) and self.uid == other.uid
cache = {}cache[User(1)] = "active"Run on 3.14, this raises TypeError: cannot use 'User' as a dict key (unhashable type: 'User') — and hash(User(1)) directly raises TypeError: unhashable type: 'User'. Two fixes:
- Define
__hash__yourself, built from the same fields__eq__compares:def __hash__(self) -> int: return hash(self.uid). - Use a frozen dataclass (
@dataclass(frozen=True)) — it generates both a correct__eq__and a matching__hash__for you, as long as every field is itself hashable.
Either fix restores hash(obj) and lets instances live in sets and as dict keys again.
NotImplemented and reflected operators
Section titled “NotImplemented and reflected operators”NotImplemented is a special singleton — not an exception — that a dunder method returns to say “I don’t know how to do this with that other type; ask someone else.” Python then tries the reflected method (__radd__ for +, __req__-style for other operators) on the other operand. If every candidate returns NotImplemented, Python falls back to a default (== and != fall back to identity; other operators raise TypeError).
def __radd__(self, other: object) -> "Vector": # supports sum([...]), which starts by computing 0 + first_item if other == 0: return self return NotImplementedMixing this up with NotImplementedError (a real exception, meant for abstract methods) is a common bug: Good(1) == "not a Good" returns False because NotImplemented triggers Python’s normal fallback, but a class that raise NotImplementedError from __eq__ instead makes the entire comparison explode — Bad(1) == "not a Bad" raises NotImplementedError: cannot compare instead of quietly returning False. Verified on 3.14: both behaviors are exactly as described.
__len__ and __getitem__
Section titled “__len__ and __getitem__”Implement __len__ to support len(obj).
Implement __getitem__ to support obj[index] subscript access.
def __len__(self) -> int: return 2
def __getitem__(self, index: int) -> float: return (self.x, self.y)[index]__iter__/__next__ vs the __getitem__ fallback
Section titled “__iter__/__next__ vs the __getitem__ fallback”The real iterator protocol is two methods: __iter__ returns an iterator (often self), and __next__ returns the next value or raises StopIteration to signal the end:
class Countdown: def __iter__(self) -> "Countdown": self.current = self.start return self
def __next__(self) -> int: if self.current <= 0: raise StopIteration self.current -= 1 return self.current + 1__getitem__ alone is also enough to make an object iterable, through a separate, legacy fallback: if a class has no __iter__, Python’s for loop calls __getitem__ with 0, 1, 2, ... until it raises IndexError, and stops there — __len__ is not involved in that fallback at all. list(LegacySequence([10, 20, 30])) runs fine and prints [10, 20, 30] on CPython. But both uvx mypy --strict and uvx pyright reject it — they only model the modern Iterable protocol (__iter__), not the legacy __getitem__ fallback: mypy reports No overload variant of "list" matches argument type "LegacySequence" [call-overload], and pyright reports "LegacySequence" is incompatible with protocol "Iterable[_T@list]" — "__iter__" is not present. This is a real gap between what CPython runs and what today’s type checkers understand — prefer __iter__/__next__ for anything you want a type checker to accept.
__len__, __bool__, __contains__
Section titled “__len__, __bool__, __contains__”def __len__(self) -> int: return len(self.items)
def __bool__(self) -> bool: return len(self.items) > 0
def __contains__(self, item: object) -> bool: return item in self.itemsbool(obj) calls __bool__ if defined; without it, Python falls back to __len__ (a length of 0 is falsy). in calls __contains__ if defined; without it, in falls back to iterating with __iter__/__getitem__.
__call__ — making instances callable
Section titled “__call__ — making instances callable”Defining __call__ lets you call an instance like a function — obj(...) becomes obj.__call__(...):
class Multiplier: def __init__(self, factor: int) -> None: self.factor = factor
def __call__(self, value: int) -> int: return value * self.factor
double = Multiplier(2)double(21) # 42callable(double) # TrueThis is how decorators-as-classes and configurable callbacks work — the object carries state (factor) that a plain function couldn’t hold without a closure.
__getattr__ vs __getattribute__
Section titled “__getattr__ vs __getattribute__”__getattribute__ runs for every attribute access on the instance, found or not — overriding it is rare and easy to get wrong (infinite recursion if it calls self.name instead of object.__getattribute__(self, name)). __getattr__ is the safer hook: it runs only when normal lookup already failed (instance __dict__ and the class’s MRO both missed), making it ideal for computed fallbacks or friendlier errors.
def __getattr__(self, name: str) -> str: # only called when normal attribute lookup FAILS return f"<no setting {name!r}>"cfg.loaded (a real attribute) never reaches __getattr__ at all; cfg.missing_setting does, because normal lookup fails first.
__del__ is not reliable
Section titled “__del__ is not reliable”__del__ runs when an object’s reference count hits zero — for a simple, cycle-free object under CPython, that happens right when the last reference is deleted, so it looks deterministic. It stops looking deterministic the moment two objects reference each other in a cycle: reference counting alone can never bring a cycle’s count to zero, so __del__ doesn’t run until the cyclic garbage collector (gc.collect(), or its automatic periodic run) breaks the cycle — which can be much later, or, in the worst case (an exception during interpreter shutdown, or gc disabled), never. Verified: deleting two nodes that reference each other prints nothing until gc.collect() runs; a cycle-free object’s __del__ prints immediately on del. Never rely on __del__ to release an external resource (a file, a socket, a lock) — use a context manager (__enter__/__exit__) instead.
functools.total_ordering
Section titled “functools.total_ordering”Implementing every comparison operator (__lt__, __le__, __gt__, __ge__) by hand is repetitive. @total_ordering fills in the rest from just __eq__ and one of the others:
from functools import total_ordering
@total_orderingclass Money: def __eq__(self, other: object) -> bool: ... def __lt__(self, other: "Money") -> bool: return self.cents < other.centssorted(prices) and Money(100) <= Money(200) both work — <= was never written by hand; total_ordering derived it from __eq__ and __lt__.
Operator overloading with __add__
Section titled “Operator overloading with __add__”Arithmetic operators map to dunder methods:
| Operator | Method |
|---|---|
a + b | a.__add__(b) |
a - b | a.__sub__(b) |
a * b | a.__mul__(b) |
a == b | a.__eq__(b) |
a < b | a.__lt__(b) |
def __add__(self, other: "Vector") -> "Vector": return Vector(self.x + other.x, self.y + other.y)Full runnable demo
Section titled “Full runnable demo”from functools import total_orderingfrom dataclasses import dataclass
class Vector: def __init__(self, x: float, y: float) -> None: self.x = x self.y = y
def __repr__(self) -> str: return f"Vector({self.x}, {self.y})"
def __str__(self) -> str: return f"({self.x}, {self.y})"
def __eq__(self, other: object) -> bool: if not isinstance(other, Vector): return NotImplemented return self.x == other.x and self.y == other.y
def __add__(self, other: "Vector") -> "Vector": return Vector(self.x + other.x, self.y + other.y)
def __radd__(self, other: object) -> "Vector": if other == 0: return self return NotImplemented
def __len__(self) -> int: return 2
def __getitem__(self, index: int) -> float: return (self.x, self.y)[index]
v1 = Vector(1, 2)v2 = Vector(3, 4)print(repr(v1))print(str(v1))print(v1)print([v1, v2]) # containers always use repr for elements, even though __str__ existsprint(v1 == Vector(1, 2))print(v1 + v2)print(sum([v1, v2])) # 0 + v1 -> Vector.__radd__ since int.__add__(0, v1) failsprint(len(v1))print(v1[0], v1[1])
try: hash(v1)except TypeError as e: print(f"TypeError: {e}")
class HashableVector: def __init__(self, x: float, y: float) -> None: self.x = x self.y = y
def __eq__(self, other: object) -> bool: if not isinstance(other, HashableVector): return NotImplemented return self.x == other.x and self.y == other.y
def __hash__(self) -> int: # Fix 1: define __hash__ alongside __eq__ return hash((self.x, self.y))
hv1 = HashableVector(1, 2)hv2 = HashableVector(1, 2)print(hash(hv1) == hash(hv2))print(len({hv1, hv2})) # equal + same hash -> the set dedupes them to 1
@dataclass(frozen=True)class FrozenVector: # Fix 2: frozen=True generates a working __hash__ for free x: float y: float
print(hash(FrozenVector(1, 2)) == hash(FrozenVector(1, 2)))
class Countdown: def __init__(self, start: int) -> None: self.start = start
def __iter__(self) -> "Countdown": self.current = self.start return self
def __next__(self) -> int: if self.current <= 0: raise StopIteration self.current -= 1 return self.current + 1
print(list(Countdown(3)))
class LegacySequence: def __init__(self, data: list[int]) -> None: self._data = data
def __getitem__(self, index: int) -> int: return self._data[index] # raises IndexError once index runs out -- for loop relies on that
print(list(LegacySequence([10, 20, 30])))
class Basket: def __init__(self, items: list[str]) -> None: self.items = items
def __len__(self) -> int: return len(self.items)
def __bool__(self) -> bool: return len(self.items) > 0
def __contains__(self, item: object) -> bool: return item in self.items
empty = Basket([])full = Basket(["apple"])print(bool(empty), bool(full))print("apple" in full)print(len(full))
class Multiplier: def __init__(self, factor: int) -> None: self.factor = factor
def __call__(self, value: int) -> int: return value * self.factor
double = Multiplier(2)print(double(21))print(callable(double))
class LazyConfig: def __init__(self) -> None: self.loaded = {"debug": False}
def __getattr__(self, name: str) -> str: # only called when normal attribute lookup FAILS return f"<no setting {name!r}>"
def __getattribute__(self, name: str) -> object: # called for EVERY attribute access, found or not print(f"looking up {name!r}") return object.__getattribute__(self, name)
cfg = LazyConfig()print(cfg.loaded)print(cfg.missing_setting)
@total_orderingclass Money: def __init__(self, cents: int) -> None: self.cents = cents
def __eq__(self, other: object) -> bool: if not isinstance(other, Money): return NotImplemented return self.cents == other.cents
def __lt__(self, other: "Money") -> bool: return self.cents < other.cents
prices = [Money(300), Money(100), Money(200)]print([m.cents for m in sorted(prices)])print(Money(100) <= Money(200)) # generated by total_ordering from __eq__ + __lt__Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”flowchart TD
A["a + b"] --> B{"a.__add__(b) returns NotImplemented?"}
B -->|"no"| R1["use a.__add__(b)'s result"]
B -->|"yes"| C{"b.__radd__(a) exists and returns NotImplemented?"}
C -->|"no"| R2["use b.__radd__(a)'s result"]
C -->|"yes"| E["TypeError: unsupported operand type(s)"] Every piece of built-in syntax is really a lookup-and-call: a + b is type(a).__add__(a, b), tried first; only if that returns NotImplemented does Python try type(b).__radd__(b, a). The same shape — “try the direct dunder, fall back to a related one, only then give up” — reappears everywhere in this lesson: bool(x) tries __bool__ then falls back to __len__; for tries __iter__/__next__ then falls back to the __getitem__ protocol; attribute access tries normal lookup then falls back to __getattr__. Reading a dunder-heavy class means asking, for each piece of syntax: which method runs first, and what does Python do if it opts out?
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Custom __eq__ | Value-based equality instead of identity | Disables __hash__ — you must add it back by hand if you need hashing |
__iter__/__next__ | Real iterator protocol, understood by type checkers | Two methods instead of one |
__getitem__-only iteration | Works with a single method, useful for existing indexable data | Not recognized as Iterable by mypy/pyright; only integer-indexed, IndexError-terminated data qualifies |
__call__ | Instances behave like functions and carry state | Easy to overuse where a plain function or method would read more clearly |
__getattr__ | Clean fallback for missing/computed attributes | Silently answering for typo’d attribute names can hide real bugs |
@total_ordering | Write 2 comparisons, get all 4 | Slightly slower than hand-written operators (an extra function call per comparison) |
Common Misconceptions
Section titled “Common Misconceptions”__str__is used everywhere__repr__isn’t. Containers (lists, dicts, tuples) always use__repr__for their elements —print([obj])never callsobj.__str__().- Any class with
__eq__can be put in a set or used as a dict key, same as before. Defining__eq__sets__hash__toNoneunless you also define__hash__— the class becomes unhashable, with aTypeErrorat the point of use, not at definition time. - Returning
Falseand raisingNotImplementedErrorfrom__eq__are similarly “safe” ways to say no.NotImplemented(no parentheses, not an exception) lets Python try the other operand and fall back gracefully;NotImplementedErroris a real exception that blows up the whole comparison. __len__is required for aforloop to work. It is not involved in iteration at all —__iter__/__next__, or the__getitem__fallback, are whatforactually uses.__del__is a reliable place to release resources, like a destructor in C++. It only runs promptly for cycle-free objects under CPython’s refcounting; a reference cycle delays it until the cyclic GC runs, and it may never run before interpreter shutdown in edge cases. Use a context manager instead.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated class full of dunders, check for:
- Is
__eq__defined without a matching__hash__? The class silently becomes unhashable — fine until someone puts an instance in a set or dict key, where it raisesTypeErrorfar from where__eq__was written. - Does a comparison dunder
raise NotImplementedErrorinstead ofreturn NotImplemented? That turns “try the other operand” into a crash. - Is
__getitem__used as the only iteration mechanism in code that will also be type-checked?mypy --strict/pyrightreject passing such an object tolist(...)or anyIterable[...]parameter, even though it runs on CPython. - Does
__getattribute__(not__getattr__) get overridden without ending inobject.__getattribute__(self, name)? Callingself.nameorgetattr(self, name)instead recurses infinitely. - Does cleanup logic (closing a file, releasing a lock) live only in
__del__? That resource may stay open far longer than intended, or never close in a cycle.
🐛 Spot the bug
class User: def __init__(self, uid: int) -> None: self.uid = uid
def __eq__(self, other: object) -> bool: return isinstance(other, User) and self.uid == other.uid
cache = {}u = User(1)cache[u] = "active"print(cache)Show the answer
Defining __eq__ without also defining __hash__ sets __hash__ to None on the class — the instance becomes unhashable. cache[u] = "active" raises TypeError: cannot use 'User' as a dict key (unhashable type: 'User') on 3.14, right at the assignment, not at class-definition time. Fix it by adding a matching __hash__: def __hash__(self) -> int: return hash(self.uid) — built from the same field(s) __eq__ compares — or switch to @dataclass(frozen=True), which generates a correct pair automatically.