13.1 Special (Dunder) Method Concepts
Dunder methods ("double underscore," also called magic methods) are methods
named __like_this__ that Python calls automatically, in response to built-in syntax
or built-in functions — never by writing obj.__add__(...) directly. a + b is really
type(a).__add__(a, b) under the hood; print(obj) is really type(obj).__str__(obj);
len(obj) is really type(obj).__len__(obj). Defining the right dunder method is how a
user-defined class plugs into that built-in syntax.
The full catalog, grouped by what each group hooks into — not just a handful of examples, but every commonly used one:
| Group | Dunders | Hooked into |
|---|---|---|
| Construction / destruction | __init__, __new__, __del__ |
ClassName(...) (10.4), garbage collection |
| Representation | __str__, __repr__ |
print()/str(), the REPL/repr() |
| Arithmetic — normal | __add__ __sub__ __mul__ __truediv__ __floordiv__ __mod__ __pow__ __matmul__ |
+ - * / // % ** @ |
| Arithmetic — reflected | __radd__ __rsub__ __rmul__ __rtruediv__ __rfloordiv__ __rmod__ __rpow__ __rmatmul__ |
same operators, operands swapped (13.2) |
| Arithmetic — in-place | __iadd__ __isub__ __imul__ __itruediv__ __ifloordiv__ __imod__ __ipow__ __imatmul__ |
+= -= *= /= //= %= **= @= (13.2) |
| Comparison | __eq__ __ne__ __lt__ __le__ __gt__ __ge__ |
== != < <= > >= |
| Hashing | __hash__ |
hash(), use as a dict/set key (5.3.1, 5.4.6) |
| Container | __len__ __getitem__ __setitem__ __delitem__ __contains__ |
len(), obj[i], obj[i] = x, del obj[i], in |
| Iteration | __iter__, __next__ |
for loops, iter()/next() |
| Callable | __call__ |
obj(...) |
| Context manager | __enter__, __exit__ |
with obj: |
| Attribute access | __getattr__ __setattr__ __delattr__ __getattribute__ |
obj.attr, obj.attr = x, del obj.attr (13.4) |
| Truthiness | __bool__ |
if obj:, bool(obj) |
Only implement the dunders a class actually needs — a plain data class might only ever
need __init__, __repr__, and __eq__; the rest of this catalog exists to be reached
for exactly when the matching syntax is genuinely useful on that class.
A class that defines __str__ and __len__ immediately gets print() and len()
support, with no other change needed:
class Box:
def __init__(self, contents):
self.contents = contents
def __str__(self):
return f"Box({self.contents!r})"
def __len__(self):
return len(self.contents)
b = Box([1, 2, 3])
print(b) # Box([1, 2, 3]) -> print() called __str__() automatically
print(len(b)) # 3 -> len() called __len__() automatically
Box([1, 2, 3]) 3
__repr__ vs. __str__. __str__ is for a readable, user-facing display — what
print()/str() use. __repr__ is for an unambiguous, developer-facing one — what
the REPL and repr() use, ideally something that looks like valid Python that could
recreate the object. The fallback relationship between them is one-directional: if
only __repr__ is defined (as with Vector in 13.2), str() automatically falls back
to it — but if only __str__ is defined, repr() does not fall back to it; it
falls back to object's default <Box object at 0x...>-style text instead. And a
list containing the object always calls repr() on each element, even when the
object has a perfectly good __str__:
class Box:
def __init__(self, contents):
self.contents = contents
def __str__(self):
return f"Box with {len(self.contents)} item(s)" # readable, user-facing
def __repr__(self):
return f"Box({self.contents!r})" # unambiguous, developer-facing
def __len__(self):
return len(self.contents)
b = Box([1, 2, 3])
print(b) # Box with 3 item(s) -> print() calls __str__
print(repr(b)) # Box([1, 2, 3]) -> repr() calls __repr__, ignores __str__ entirely
print([b]) # [Box([1, 2, 3])] -> a list prints repr() of its elements, even though __str__ exists
Box with 3 item(s) Box([1, 2, 3]) [Box([1, 2, 3])]
In practice — this is what makes a stack trace actually useful. When an
Order or User object shows up in a log line or an unhandled exception's traceback,
seeing Order(id=42, status='paid') instead of <Order object at 0x7f3a1c> is the
difference between debugging a production incident in seconds versus having to go
add print statements and reproduce it again — a useful __repr__ on every domain
class is a small, cheap habit with an outsized payoff during an outage.
Comparison dunders work the same way — == calls __eq__, < calls __lt__, and
so on. By default, == compares identity (2.4) — defining __eq__ replaces that with
whatever comparison actually makes sense for the class:
class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
return self.amount == other.amount # compare values, not identity
def __lt__(self, other):
return self.amount < other.amount
a = Money(10)
b = Money(10)
c = Money(20)
print(a == b) # True -> calls a.__eq__(b), compares amounts
print(a is b) # False -> still two separate objects (2.4)
print(a < c) # True -> calls a.__lt__(c)
True False True
Common mistake — assuming the other comparisons come for free. Comparison operators
pair up as reflections of each other — </>, <=/>=, and ==/!= (each its own
reflection) — so a > c actually tries a.__gt__(c) first, and only because that's
missing does Python fall back to the reflected form, c.__lt__(a). <= has no
such relationship to __lt__ at all — it's paired with __ge__ instead — so it fails
outright unless defined:
print(a > c) # False -> no __gt__ defined, so Python tries c.__lt__(a) instead: 20 < 10 is False
try:
a <= c # <= has nothing to do with __lt__ - it needs __le__ (or __ge__ on the other side)
except TypeError as e:
print("TypeError:", e) # '<=' not supported between instances of 'Money' and 'Money'
print(a != c) # True -> __ne__ wasn't defined either, but Python auto-inverts __eq__ for it
False TypeError: '<=' not supported between instances of 'Money' and 'Money' True
Common mistake — defining __eq__ silently makes instances unhashable. Python's
default object.__hash__ assumes equal objects hash the same, which the
identity-based default can no longer promise once a class defines its own value-based
__eq__ — so Python responds by setting that class's __hash__ to None
automatically. That breaks anything needing a hash: hash() directly, or using the
object as a dict key/set member (5.3.1, 5.4.6):
try:
hash(a) # a = Money(10), from above - __eq__ was defined, so Python set Money.__hash__ = None
except TypeError as e:
print("TypeError:", e) # unhashable type: 'Money'
try:
{a} # a set needs to hash its members too
except TypeError as e:
print("TypeError:", e) # unhashable type: 'Money'
TypeError: unhashable type: 'Money' TypeError: unhashable type: 'Money'
The fix — define __hash__ explicitly, alongside __eq__, so that objects
considered equal also hash identically (a hard requirement for correct dict/set
behavior):
class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
return self.amount == other.amount
def __lt__(self, other):
return self.amount < other.amount
def __hash__(self): # must be re-defined explicitly - not inherited automatically
return hash(self.amount) # equal Money objects now hash identically too
m1 = Money(10)
m2 = Money(10)
print(m1 == m2, hash(m1) == hash(m2)) # True True -> equal objects, equal hashes, as hashing requires
print(len({m1, m2})) # 1 -> the set correctly treats them as duplicates
True True 1
In practice — value objects like Money or a geographic Coordinate. A real
billing system routinely needs to deduplicate or group transactions by amount, and a
mapping/geospatial system needs to use coordinates as dict keys or set members for
fast lookup — both require exactly the correctly-paired __eq__/__hash__ shown
above; getting only one of the two defined is a real, easy-to-hit bug in this kind of
code.
What's allowed: overloading vs. overriding, at a glance. Two ideas get confused constantly — "redefining the same name more than once in one class" (overloading) and "redefining a name a parent class already gave a class" (overriding) are entirely different mechanisms, with entirely different levels of support:
| Allowed in Python? | Why | |
|---|---|---|
| Operator overloading | ✅ yes | implement the matching dunder (__add__, __eq__, ...) — this whole section is about exactly that |
| Method overloading | ❌ no | a class body just runs top to bottom; a second def greet(...) replaces the first, it doesn't add a second signature |
| Constructor overloading | ❌ no | __init__ is just a method — the same replace-not-add rule applies to it too |
| Method overriding | ✅ yes | a subclass redefining a method its parent already had — this is routine, everyday inheritance (12.3) |
| Constructor overriding | ✅ yes | a subclass redefining __init__ — also routine, typically paired with super().__init__(...) (12.4) |
The next two sections dive into each half of that table in detail: 13.2 covers operator overloading (the "yes" case above involving operators specifically), and 13.3 covers why method/constructor overloading fails and confirms that overriding, by contrast, works exactly as expected.
13.2 Operator Overloading
Python doesn't let you change what + does for built-in types, but operator
overloading is fully supported for your own classes: implement the matching dunder
method, and that operator works on your objects too. v1 + v2 calls
v1.__add__(v2):
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6) -> calls v1.__add__(v2)
Vector(4, 6)
Reflected methods handle the operator with the operand order flipped. 5 + v1
first tries (5).__add__(v1) — but int has no idea what a Vector is, so it backs
off (returns NotImplemented), and Python then tries v1.__radd__(5) instead. Without
__radd__ defined, both sides give up and it's a TypeError:
try:
5 + v1 # int.__add__(5, v1) -> NotImplemented, and Vector has no __radd__ either
except TypeError as e:
print("TypeError:", e) # unsupported operand type(s) for +: 'int' and 'Vector'
TypeError: unsupported operand type(s) for +: 'int' and 'Vector'
This is exactly why sum() silently fails on a list of custom objects — sum()
always starts from 0 and computes 0 + first_item before adding the rest, which hits
this exact int + Vector case:
vectors = [Vector(1, 1), Vector(2, 2), Vector(3, 3)]
try:
total = sum(vectors) # internally: 0 + Vector(1,1) + Vector(2,2) + Vector(3,3)
except TypeError as e:
print("TypeError:", e) # unsupported operand type(s) for +: 'int' and 'Vector'
TypeError: unsupported operand type(s) for +: 'int' and 'Vector'
The fix: implement __radd__ and special-case 0 — the one value sum() will ever
add from the left that isn't a real Vector:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __radd__(self, other): # called for "other + self" when other's __add__ gave up
if other == 0: # handles sum()'s implicit "0 + first_item" start
return self
return NotImplemented
def __repr__(self):
return f"Vector({self.x}, {self.y})"
vectors = [Vector(1, 1), Vector(2, 2), Vector(3, 3)]
print(sum(vectors)) # Vector(6, 6) -> works now
Vector(6, 6)
In practice — this is literally how NumPy arrays support +/*. Writing
array1 + array2 to add two NumPy arrays element-wise, or Money(10) + Money(5) in a
billing system so + respects currency rules, works through exactly this mechanism —
a library or domain class implementing __add__/__radd__ (and friends) so its
objects behave naturally with Python's own arithmetic syntax instead of needing a
separate .add() method call.
Common mistake — confusing NotImplemented with NotImplementedError. They read
almost identically but are entirely different things. NotImplemented (returned
above, in __radd__) is a sentinel object — returning it tells Python's operator
dispatch to try the other operand instead, exactly how 5 + v1 above ended up trying
v1.__radd__(5). NotImplementedError (raised in 12.6's Shape.area) is an ordinary
exception — raising it stops execution immediately, signaling "this method
genuinely isn't written yet." Returning NotImplementedError instead of
NotImplemented (or raising NotImplemented instead of NotImplementedError) is a
real, easy-to-make mix-up that silently breaks the mechanism each one is meant to
support:
class Oops:
def __add__(self, other):
return NotImplementedError # BUG: the EXCEPTION CLASS, not the NotImplemented sentinel
o = Oops()
print(o + 1) # <class 'NotImplementedError'> -> no error at all, just garbage output instead of a clean TypeError
try:
raise NotImplemented # BUG: NotImplemented is a plain object, not an exception - it can't be raised
except TypeError as e:
print("TypeError:", e) # exceptions must derive from BaseException
<class 'NotImplementedError'> TypeError: exceptions must derive from BaseException
In-place methods handle the augmented form directly — obj += x calls
obj.__iadd__(x) if it exists, and unlike __add__ it's expected to mutate the
object itself and return it, rather than building a new one:
class Basket:
def __init__(self, items):
self.items = items
def __iadd__(self, other):
self.items.extend(other) # mutate in place...
return self # ...and return self, instead of a new object
def __repr__(self):
return f"Basket({self.items})"
basket = Basket([1, 2])
original_id = id(basket)
basket += [3, 4] # calls basket.__iadd__([3, 4])
print(basket) # Basket([1, 2, 3, 4])
print(id(basket) == original_id) # True -> same object, mutated in place
Basket([1, 2, 3, 4]) True
Without __iadd__, += just falls back to obj = obj.__add__(x) — which builds a
new object and rebinds the name to it:
class NoInPlace:
def __init__(self, items):
self.items = items
def __add__(self, other):
return NoInPlace(self.items + other) # always builds a NEW object
def __repr__(self):
return f"NoInPlace({self.items})"
n = NoInPlace([1, 2])
original_id = id(n)
n += [3, 4] # no __iadd__ -> falls back to n = n + [3, 4]
print(n) # NoInPlace([1, 2, 3, 4])
print(id(n) == original_id) # False -> a different object now
NoInPlace([1, 2, 3, 4]) False
The full arithmetic operator family — every operator follows the same three-part pattern just demonstrated (normal, reflected, in-place):
| Operator | Normal | Reflected | In-place |
|---|---|---|---|
+ |
__add__ |
__radd__ |
__iadd__ |
- |
__sub__ |
__rsub__ |
__isub__ |
* |
__mul__ |
__rmul__ |
__imul__ |
/ |
__truediv__ |
__rtruediv__ |
__itruediv__ |
// |
__floordiv__ |
__rfloordiv__ |
__ifloordiv__ |
% |
__mod__ |
__rmod__ |
__imod__ |
** |
__pow__ |
__rpow__ |
__ipow__ |
@ (matrix multiplication, 13.1) |
__matmul__ |
__rmatmul__ |
__imatmul__ |
& |
__and__ |
__rand__ |
__iand__ |
\| |
__or__ |
__ror__ |
__ior__ |
^ |
__xor__ |
__rxor__ |
__ixor__ |
<< |
__lshift__ |
__rlshift__ |
__ilshift__ |
>> |
__rshift__ |
__rrshift__ |
__irshift__ |
Only implement the ones an operation actually makes sense for — Vector above only
needed __add__/__radd__/__iadd__; a class with no sensible % or << behavior
simply leaves those dunders undefined, and Python raises TypeError for that operator
on its own, exactly like it did for 5 + v1 above.
13.3 Method Overloading Behaviour in Python
Overloading — defining multiple versions of the same name, distinguished by
signature (different parameter counts/types) — is not supported in Python, for
either ordinary methods or constructors. A class body just executes top to bottom
(9.1's "code that runs" idea, applied to a class, per 10.4): a second def with the
same name doesn't add an overload, it simply replaces the first definition, which
is now gone entirely:
class Greeter:
def greet(self): # this definition...
return "Hello!"
def greet(self, name): # ...is completely replaced by this one
return f"Hello, {name}!"
g = Greeter()
print(g.greet("Ada")) # Hello, Ada! -> only the one-argument version exists at all now
try:
g.greet() # the zero-argument version is simply gone
except TypeError as e:
print("TypeError:", e) # Greeter.greet() missing 1 required positional argument: 'name'
Hello, Ada! TypeError: Greeter.greet() missing 1 required positional argument: 'name'
The exact same thing happens to __init__ — it's just another method name, with no
special exemption (10.4 first showed this exact gotcha):
class Point:
def __init__(self, x): # this constructor...
self.x = x
self.y = 0
def __init__(self, x, y): # ...is completely replaced by this one
self.x = x
self.y = y
p = Point(1, 2)
print(p.x, p.y) # 1 2 -> only the two-argument version exists at all
try:
Point(1) # the one-argument version is simply gone
except TypeError as e:
print("TypeError:", e) # Point.__init__() missing 1 required positional argument: 'y'
1 2 TypeError: Point.__init__() missing 1 required positional argument: 'y'
The idiomatic alternative is a single method with default/variable arguments (6.2) — one definition that handles every call shape, instead of several competing definitions:
class Greeter:
def greet(self, name=None): # one method, optional parameter, instead of two overloads
if name is None:
return "Hello!"
return f"Hello, {name}!"
g = Greeter()
print(g.greet()) # Hello!
print(g.greet("Ada")) # Hello, Ada!
Hello! Hello, Ada!
Going deeper — default/variable arguments (above) handle one function with
optional pieces, but that's not true type-based dispatch. For genuinely different logic
per type of argument, functools.singledispatch (for plain functions) and
functools.singledispatchmethod (for methods) are the standard-library, real-world way
professional Python code gets overload-like behavior — registering one implementation
per type instead of branching manually inside a single function body.
Overriding is a completely different thing, and it is fully supported — both for
ordinary methods and for __init__. The distinction:
| Overloading | Overriding | |
|---|---|---|
| Where | same class, same name, different signatures | parent and child class, same name |
| Supported in Python? | no — later definition just replaces the earlier one | yes — this is routine, everyday inheritance |
| Covered in | this section | 12.3 (methods), 12.4/10.4 (__init__, via super()) |
A subclass redefining __init__ or any other method — in a different class from
where it was first defined — works exactly as expected, with no ambiguity at all:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "some sound"
class Dog(Animal):
def __init__(self, name, breed): # overriding __init__ - a DIFFERENT class, allowed
super().__init__(name)
self.breed = breed
def speak(self): # overriding speak() - allowed
return "Woof!"
d = Dog("Rex", "Labrador")
print(d.name, d.breed, d.speak()) # Rex Labrador Woof!
Rex Labrador Woof!
13.4 Getter and Setter Methods
The classic OOP pattern from other languages — a plain get_x()/set_x() method pair
guarding a "private" field — works fine in Python too:
class Person:
def __init__(self, name):
self._name = name
def get_name(self): # traditional getter method
return self._name
def set_name(self, value): # traditional setter method
if not value:
raise ValueError("name cannot be empty")
self._name = value
p = Person("Ada")
print(p.get_name()) # Ada
p.set_name("Grace")
print(p.get_name()) # Grace
try:
p.set_name("")
except ValueError as e:
print("ValueError:", e) # name cannot be empty
Ada Grace ValueError: name cannot be empty
This works, but it's not the idiomatic Python style — @property (10.6) does the same
job while keeping the caller-facing syntax as plain attribute access (p.name, not
p.get_name()). Both get_name()/set_name() and a @property handle one named
attribute. The dunders below are different in kind: each one intercepts every
attribute on the object at once, generically.
__getattr__ is a fallback — it only runs when normal attribute lookup already
failed. It's the tool for attributes that don't really exist as stored data, but can
be computed on demand:
class Circle:
def __init__(self, radius):
self.radius = radius
def __getattr__(self, name):
# only called when normal lookup FAILS - "radius" itself never reaches here
if name == "area":
return 3.14159 * self.radius ** 2
raise AttributeError(f"'Circle' object has no attribute {name!r}")
c = Circle(2)
print(c.radius) # 2 -> found normally, __getattr__ never even runs
print(c.area) # 12.56636 -> "area" lookup failed, __getattr__ computed it on the fly
try:
c.diameter
except AttributeError as e:
print("AttributeError:", e) # 'Circle' object has no attribute 'diameter'
2 12.56636 AttributeError: 'Circle' object has no attribute 'diameter'
In practice — lazy-loading related data, the way an ORM does. An ORM object
representing a database row often defers fetching a related record (e.g. an Order's
customer) until the attribute is actually accessed for the first time —
implemented with exactly this __getattr__ fallback, running the extra query only
when the data is genuinely needed instead of eagerly loading everything up front.
__setattr__ is not a fallback — it runs on every single attribute assignment,
letting one method act like a setter for the whole object at once:
class Logged:
def __setattr__(self, name, value):
print(f"setting {name} = {value!r}")
super().__setattr__(name, value) # delegate to the real assignment
l = Logged()
l.x = 10 # setting x = 10
l.y = "hello" # setting y = 'hello'
print(vars(l)) # {'x': 10, 'y': 'hello'}
setting x = 10
setting y = 'hello'
{'x': 10, 'y': 'hello'}Going deeper — everything above assumes instances keep their attributes in a
per-instance __dict__, which is exactly what vars(obj) reads (vars() is literally
shorthand for obj.__dict__). A class built with __slots__ (10.2) has no
instance __dict__ at all: vars() raises TypeError on such an instance, and a
__getattr__ fallback can't lean on scanning a dict that was never created.
Common mistake — infinite recursion. self.x = value inside __setattr__ would
call __setattr__ again to perform that very assignment — forever. The
super().__setattr__(name, value) call above is what actually stores it, by reaching
past this override to the real, underlying assignment mechanism:
import sys
sys.setrecursionlimit(50) # fail fast for this demo, instead of hanging
class Broken:
def __setattr__(self, name, value):
self.name = value # BUG: this assignment calls __setattr__ again -> infinite recursion
try:
broken = Broken()
broken.x = 1
except RecursionError as e:
print("RecursionError:", e) # maximum recursion depth exceeded
RecursionError: maximum recursion depth exceeded
__delattr__ is the same idea, for del obj.attr (2.5) — and needs the same
super() delegation to avoid the same recursion trap:
class LoggedDelete:
def __delattr__(self, name):
print(f"deleting {name}")
super().__delattr__(name)
ld = LoggedDelete()
ld.x = 10
del ld.x # deleting x
print(vars(ld)) # {}
deleting x
{}__getattribute__ is the most powerful — and most dangerous — of the four. Unlike
__getattr__, it runs on every attribute read, even ones that already exist, which
means it's rarely overridden directly; almost any use of self.anything inside it would
trigger itself again. Delegating to super().__getattribute__(name) for the actual
lookup is what keeps it from recursing:
class Watched:
def __init__(self, x):
self.x = x
def __getattribute__(self, name):
print(f"accessing {name!r}")
return super().__getattribute__(name) # delegate to the real lookup, avoid recursion
w = Watched(5)
print(w.x)
# accessing 'x' -> printed even though x already exists - __getattribute__ runs for EVERY access
# 5
accessing 'x' 5
13.5 Container and Callable Protocols
13.1's catalog table listed the container protocol (__len__ __getitem__
__setitem__ __delitem__ __contains__) and the callable protocol (__call__)
without ever actually implementing most of them — Box (13.1) only defined __str__
and __len__ so far. Filling in the rest turns it into something that supports
box[i], box[i] = x, del box[i] and x in box, exactly like a list:
class Box:
def __init__(self, contents):
self.contents = list(contents)
def __str__(self):
return f"Box({self.contents!r})"
def __len__(self):
return len(self.contents) # len(box)
def __getitem__(self, index):
return self.contents[index] # box[i]
def __setitem__(self, index, value):
self.contents[index] = value # box[i] = value
def __delitem__(self, index):
del self.contents[index] # del box[i]
def __contains__(self, item):
return item in self.contents # item in box
box = Box([1, 2, 3])
print(len(box)) # 3 -> __len__
print(box[1]) # 2 -> __getitem__
box[1] = 99 # __setitem__
print(box[1]) # 99
del box[0] # __delitem__
print(box.contents) # [99, 3]
print(99 in box, 5 in box) # True False -> __contains__
3 2 99 [99, 3] True False
In practice — a paginated API results wrapper. A class wrapping one page of API
results so callers can write len(results), results[0], and "item-42" in results
exactly as if it were a plain list — while it might actually be lazily fetching more
pages behind the scenes — is a very common real use of the container protocol,
alongside game-development classes like a Deck of cards or a Hand of tiles.
The callable protocol — __call__ makes instances directly callable with
obj(...), just like a function. A minimal example: a Multiplier whose instances
remember a factor and apply it when called:
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
print(double(5)) # 10 -> double(5) is really double.__call__(5)
print(callable(double)) # True -> callable() checks whether __call__ exists
10 True