Python Magic (Dunder) Methods
Learn Python magic methods (__init__, __str__, __repr__, operator overloading, container and context manager protocols) with working examples.
Magic methods — also called dunder methods (short for double-underscore) — are special methods whose names begin and end with two underscores, like __init__ or __len__. They are Python's hook system: by defining them in your own classes you tell Python how an object should behave with built-in operators and functions such as +, len(), print(), in, and with.
You never call dunder methods directly. Instead Python calls them automatically behind the scenes:
| Python expression | Dunder called |
|---|---|
str(obj) | obj.__str__() |
len(obj) | obj.__len__() |
a + b | a.__add__(b) |
a == b | a.__eq__(b) |
item in obj | obj.__contains__(item) |
with obj as x: | obj.__enter__() / obj.__exit__(...) |
This chapter covers:
- String representation —
__repr__and__str__ - Comparison operators —
__eq__,__lt__, and friends - Arithmetic operators —
__add__,__mul__,__rmul__, and more - Container protocol —
__len__,__getitem__,__contains__ - Iterator protocol —
__iter__and__next__ - Truthiness —
__bool__ - Callable objects —
__call__ - Context manager protocol —
__enter__and__exit__ __hash__— making objects usable as dictionary keys
Before reading, make sure you are comfortable with Python classes and objects and Python inheritance. For computed attributes, see @property.
String Representation: __repr__ and __str__
These two methods control how an object is converted to a string.
| Method | Called by | Purpose |
|---|---|---|
__repr__ | repr(), interactive shell | Unambiguous, developer-facing representation |
__str__ | str(), print(), f-strings | Human-friendly display |
If __str__ is not defined, Python falls back to __repr__. It is therefore best practice to always define __repr__ and define __str__ only when you want a different human-readable format.
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, pages={self.pages})"
def __str__(self):
return f'"{self.title}" by {self.author} ({self.pages} pages)'
b = Book("Clean Code", "Robert C. Martin", 431)
print(repr(b)) # Book(title='Clean Code', author='Robert C. Martin', pages=431)
print(str(b)) # "Clean Code" by Robert C. Martin (431 pages)
print(b) # "Clean Code" by Robert C. Martin (431 pages)The !r conversion flag inside an f-string calls repr() on that value, which wraps strings in quotes. This makes __repr__ output copy-pasteable Python code.
Tip: a good __repr__ lets you reconstruct the object from its output. Think of it as eval(repr(obj)) == obj as a mental model (even when not literally true).
Comparison Operators
Python comparison operators all map to dunder methods. Define them when you want ==, <, >, <=, or >= to compare your objects meaningfully.
| Operator | Method | Reflected method |
|---|---|---|
== | __eq__ | __eq__ |
!= | __ne__ | __ne__ |
< | __lt__ | __gt__ |
<= | __le__ | __ge__ |
> | __gt__ | __lt__ |
>= | __ge__ | __le__ |
Reflected means Python tries the right operand's method when the left operand returns NotImplemented. For example, if a < b calls a.__lt__(b) and that returns NotImplemented, Python then tries b.__gt__(a).
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __eq__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius == other.celsius
def __lt__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius < other.celsius
def __le__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius <= other.celsius
def __repr__(self):
return f"Temperature({self.celsius}°C)"
t1 = Temperature(20)
t2 = Temperature(30)
t3 = Temperature(20)
print(t1 == t3) # True
print(t1 < t2) # True
print(t2 > t1) # True — Python derives __gt__ from __lt__ via reflection
print(t1 <= t3) # TrueShortcut: if you only want objects to be sortable without caring about the individual six operators, use the @functools.total_ordering decorator. Define __eq__ and one of __lt__, __le__, __gt__, or __ge__, and total_ordering fills in the rest automatically.
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
return self.celsius == other.celsius
def __lt__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius < other.celsiusArithmetic Operators
Arithmetic dunders let your objects work with +, -, *, /, //, %, and **.
| Expression | Method | Notes |
|---|---|---|
a + b | __add__ | |
a - b | __sub__ | |
a * b | __mul__ | |
b * a | __rmul__ | right-hand version; called when b.__mul__(a) returns NotImplemented |
-a | __neg__ | unary negation |
abs(a) | __abs__ | |
a += b | __iadd__ | in-place; falls back to __add__ if not defined |
A classic use-case is a 2D vector class:
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 __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar): # supports: 3 * v
return self.__mul__(scalar)
def __neg__(self):
return Vector(-self.x, -self.y)
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v2 - v1) # Vector(2, 2)
print(v1 * 3) # Vector(3, 6)
print(3 * v1) # Vector(3, 6) — uses __rmul__
print(-v1) # Vector(-1, -2)
print(abs(v2)) # 5.0__rmul__ is what allows 3 * v1 to work. When Python evaluates 3 * v1, it first calls int.__mul__(3, v1). The built-in integer class does not know how to multiply an integer by a Vector, so it returns NotImplemented. Python then tries the reflected method: v1.__rmul__(3), which succeeds.
Container Protocol
Implement these methods to make your class behave like a sequence or collection.
| Method | Called by | What it enables |
|---|---|---|
__len__ | len(obj) | Length of the container |
__getitem__ | obj[index] | Index and slice access |
__setitem__ | obj[index] = val | Assignment by index |
__delitem__ | del obj[index] | Deletion by index |
__contains__ | item in obj | Membership test |
Defining __len__ together with __getitem__ is enough to make your class automatically iterable — Python's for loop will call __getitem__ with successive indices starting from 0 until it gets an IndexError.
class WordBag:
def __init__(self, *words):
self._words = list(words)
def __len__(self):
return len(self._words)
def __contains__(self, item):
return item in self._words
def __getitem__(self, index):
return self._words[index]
def __repr__(self):
return f"WordBag({self._words!r})"
bag = WordBag("apple", "banana", "cherry")
print(len(bag)) # 3
print("banana" in bag) # True
print("grape" in bag) # False
print(bag[0]) # apple
print(bag[-1]) # cherry
# __len__ + __getitem__ makes the object iterable automatically
for word in bag:
print(word)
# apple
# banana
# cherryIterator Protocol
If you want full iterator behaviour (working with iter() and next() directly, or being usable in places that require an iterator rather than just an iterable), define both __iter__ and __next__:
__iter__— called byiter(obj)and at the start of aforloop; should return the iterator object (usuallyself).__next__— called repeatedly to produce the next value; should raiseStopIterationwhen exhausted.
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
self.current = self.start
return self
def __next__(self):
if self.current < 0:
raise StopIteration
value = self.current
self.current -= 1
return value
for n in Countdown(3):
print(n)
# 3
# 2
# 1
# 0For more powerful iteration patterns — especially lazy sequences that produce values on demand — see Python Generators and Python Iterators.
Truthiness: __bool__
Python calls __bool__ when an object is used in a boolean context (an if statement, while loop, not, and, or). If __bool__ is not defined but __len__ is, Python uses len(obj) != 0 as the truth value. If neither is defined, the object is always truthy.
class Stack:
def __init__(self):
self._data = []
def push(self, item):
self._data.append(item)
def pop(self):
return self._data.pop()
def __len__(self):
return len(self._data)
def __bool__(self):
return len(self._data) > 0
def __repr__(self):
return f"Stack({self._data!r})"
s = Stack()
print(bool(s)) # False — empty stack is falsy
s.push(1)
print(bool(s)) # True
print(len(s)) # 1
if s:
print("stack has items") # stack has itemsThis mirrors how built-in collections work: an empty list, dict, or set is falsy; a non-empty one is truthy.
Callable Objects: __call__
Defining __call__ lets you use an instance as if it were a function. This is useful for objects that maintain state between calls — something a plain function cannot do without a closure or a global variable.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
double = Multiplier(2)
triple = Multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(callable(double)) # Truedouble and triple are ordinary objects, but you call them with () just like functions. The built-in callable() function returns True for any object that has __call__.
This pattern is common in machine-learning frameworks (layers, loss functions) and in decorator factories. See Python Decorators for a closely related use-case.
Context Manager Protocol: __enter__ and __exit__
The with statement is Python's way to set up and tear down a resource reliably — even if an exception occurs. Any object that defines __enter__ and __exit__ can be used as a context manager.
__enter__(self)— runs when thewithblock starts; its return value is bound to theasvariable.__exit__(self, exc_type, exc_val, exc_tb)— runs when the block exits, whether normally or via an exception. ReturnTrueto suppress the exception; returnFalse(orNone) to let it propagate.
class ManagedFile:
def __init__(self, path, mode="r"):
self.path = path
self.mode = mode
self._file = None
def __enter__(self):
self._file = open(self.path, self.mode)
return self._file # the value bound to the "as" variable
def __exit__(self, exc_type, exc_val, exc_tb):
if self._file:
self._file.close()
return False # do not suppress exceptions
with ManagedFile("/etc/hostname") as f:
content = f.read()
# The file is guaranteed to be closed here, even if an exception occurred inside the block.The standard library's contextlib.contextmanager decorator lets you write the same logic as a generator function — a lighter alternative for simple cases. See Python with Statement for full coverage.
Hashing: __hash__
Python uses __hash__ to place objects into sets and dictionaries. The default __hash__ is based on the object's memory address (identity). When you override __eq__, Python automatically sets __hash__ to None, making your objects unhashable — you must define __hash__ explicitly if you still want them to work in sets or as dict keys.
The rule: objects that compare equal must have the same hash.
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):
return hash((self.x, self.y)) # hash of an immutable tuple
def __repr__(self):
return f"Point({self.x}, {self.y})"
p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)
print(p1 == p2) # True
print(p1 is p2) # False — different objects in memory
print(hash(p1) == hash(p2)) # True
seen = {p1, p2, p3}
print(len(seen)) # 2 — p1 and p2 are equal, so only one copy kept
print(p1 in seen) # TrueIf your class is mutable (its fields can change after creation), do not define __hash__. Mutable objects should not be hashable because changing their fields would change their hash, breaking any set or dict that already contains them.
Summary Table
| Category | Method | Triggered by |
|---|---|---|
| Representation | __repr__ | repr(obj), interactive shell |
| Representation | __str__ | str(obj), print(obj), f-strings |
| Comparison | __eq__, __ne__ | ==, != |
| Comparison | __lt__, __le__, __gt__, __ge__ | <, <=, >, >= |
| Arithmetic | __add__, __sub__, __mul__ | +, -, * |
| Arithmetic | __rmul__, __radd__, … | right-hand reflected forms |
| Arithmetic | __neg__, __abs__ | unary -, abs() |
| Container | __len__ | len(obj) |
| Container | __getitem__, __setitem__, __delitem__ | obj[i], obj[i] = v, del obj[i] |
| Container | __contains__ | item in obj |
| Iterator | __iter__ | iter(obj), for loop |
| Iterator | __next__ | next(obj) |
| Truthiness | __bool__ | bool(obj), if obj: |
| Callable | __call__ | obj(args) |
| Context manager | __enter__, __exit__ | with obj as x: |
| Hashing | __hash__ | hash(obj), dict keys, sets |
When to Use Magic Methods
- Use them when your class represents a value type (a point, a vector, a money amount, a date range) — overloading operators and comparison makes the class feel natural.
- Use them when your class wraps a resource (a file, a database connection, a network socket) —
__enter__/__exit__ensures the resource is always released. - Use them when your class is a custom collection — the container and iterator protocols let it work with
for,in,len(), and list comprehensions. - Avoid them for ordinary application classes that are not value types or containers. Overloading
+on aUserclass would be confusing.
For more advanced OOP patterns, see Python Abstract Classes, Python Encapsulation, and Python Polymorphism.