Python Magic Methods
Python’s magic methods — also called dunder methods because their names start and end with double underscores, like __init__ or __add__ — are special hooks that let your own classes plug into Python’s built-in syntax. They are what make len(obj), obj[0], obj1 + obj2, for x in obj, and with obj as f work for objects you write yourself, not just for built-in types like lists and strings. Once you understand magic methods, you stop treating Python’s operators and built-in functions as fixed rules and start seeing them as a protocol: any object that implements the right dunder methods gets to participate.
Overview: How Magic Methods Work
Every piece of “special” syntax in Python is really just a call to a dunder method behind the scenes. When you write a + b, the interpreter does not know anything about addition for your custom class — it simply calls type(a).__add__(a, b). When you write len(obj), Python calls type(obj).__len__(obj). When you write for item in obj, Python calls type(obj).__iter__(obj) to get an iterator, then repeatedly calls __next__ on it. This is sometimes called operator overloading, but it is really a general mechanism: Python’s syntax is a thin layer over dunder method calls.
This has an important consequence that trips up many learners: special method lookup happens on the type (class), not on the instance. If you try to attach a method directly to a single object — obj.__len__ = lambda: 5 — len(obj) will still fail, because implicit dunder calls skip the instance __dict__ entirely and look at type(obj).__mro__. This is a deliberate CPython optimization and correctness guarantee: it lets the interpreter assume operators behave consistently for every instance of a class, and it lets built-in types stay fast because they never need to check an instance dictionary for overrides.
Magic methods fall into a few natural families: construction (__init__, __new__, __del__), representation (__repr__, __str__), comparison (__eq__, __lt__, …), arithmetic (__add__, __mul__, …), container behavior (__len__, __getitem__, __iter__, __contains__), callables (__call__), and context managers (__enter__, __exit__). You almost never call these methods directly — you call len(obj), not obj.__len__(), and you let +, in, with, and for trigger them for you. Implementing the right dunder methods is how a custom class stops being a bag of unrelated attributes and starts feeling like a first-class citizen of the language.
Syntax
Magic methods are defined like ordinary instance methods inside a class body, using the exact reserved name Python expects:
class ClassName:
def __method_name__(self, ...other args...):
...
return some_value
The table below lists the most commonly implemented magic methods and the syntax or built-in function that triggers each one.
| Magic Method | Triggered By | Purpose |
|---|---|---|
__init__(self, ...) |
ClassName(...) |
Initialize a new instance’s attributes |
__repr__(self) |
repr(obj), the REPL, containers |
Unambiguous, developer-facing representation |
__str__(self) |
str(obj), print(obj) |
Readable, user-facing representation |
__eq__(self, other) |
obj == other |
Equality comparison |
__lt__, __le__, __gt__, __ge__ |
<, <=, >, >= |
Ordering comparisons |
__hash__(self) |
hash(obj), set/dict keys |
Hash value for hashable objects |
__add__, __sub__, __mul__, __truediv__ |
+, -, *, / |
Arithmetic operators |
__len__(self) |
len(obj) |
Length of a container |
__getitem__(self, key) |
obj[key] |
Indexing and slicing lookup |
__setitem__(self, key, value) |
obj[key] = value |
Indexed assignment |
__iter__(self) |
for x in obj, iter(obj) |
Return an iterator |
__contains__(self, item) |
item in obj |
Membership test |
__call__(self, ...) |
obj(...) |
Make instances callable like functions |
__enter__ / __exit__ |
with obj as x: |
Context manager protocol |
__bool__(self) |
if obj:, bool(obj) |
Truthiness |
Examples
Example 1: Representation with __repr__ and __str__
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Point(x={self.x}, y={self.y})"
def __str__(self) -> str:
return f"({self.x}, {self.y})"
p1 = Point(3, 4)
p2 = Point(1, 2)
print(p1)
print(repr(p1))
print([p1, p2])
Output:
(3, 4)
Point(x=3, y=4)
[Point(x=3, y=4), Point(x=1, y=2)]
This example shows the two representation hooks. print(p1) calls __str__, giving the friendly (3, 4). Calling repr(p1) explicitly — or letting a list format its elements — calls __repr__ instead, which by convention should be unambiguous enough to help with debugging, ideally something you could paste back into Python to recreate the object. If a class doesn’t define __str__, Python silently falls back to __repr__ for both print() and str(), which is why many classes only bother implementing __repr__.
Example 2: Arithmetic and Comparison with __add__, __eq__, __lt__
from functools import total_ordering
@total_ordering
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 __add__(self, other: "Vector") -> "Vector":
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.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 __lt__(self, other: "Vector") -> bool:
if not isinstance(other, Vector):
return NotImplemented
return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)
def __hash__(self) -> int:
return hash((self.x, self.y))
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)
print(v1 == Vector(1, 2))
print(v1 < v2)
print(v1 <= Vector(1, 2))
print(len({v1, Vector(1, 2), v2}))
Output:
Vector(4, 6)
True
True
True
2
v1 + v2 calls __add__, which builds a new Vector. Both __add__ and __eq__ return the special value NotImplemented — not False and not an exception — whenever other isn’t a Vector, so Python can fall back to the other operand’s reflected method instead of failing outright. The @total_ordering decorator from functools fills in __le__, __gt__, and __ge__ automatically once you supply __eq__ and one ordering method like __lt__, which is why v1 <= Vector(1, 2) works even though __le__ was never written. Finally, because __hash__ is defined consistently with __eq__, equal vectors collapse to one entry in a set, so the three-element literal reports a length of 2.
Example 3: Building a Container with __len__, __getitem__, and __iter__
class Playlist:
def __init__(self, songs: list[str]) -> None:
self._songs = list(songs)
def __len__(self) -> int:
return len(self._songs)
def __getitem__(self, index: int) -> str:
return self._songs[index]
def __iter__(self):
return iter(self._songs)
def __contains__(self, song: str) -> bool:
return song in self._songs
def __repr__(self) -> str:
return f"Playlist({self._songs!r})"
playlist = Playlist(["Intro", "Solar", "Nightdrive"])
print(len(playlist))
print(playlist[1])
for song in playlist:
print(f"Now playing: {song}")
print("Solar" in playlist)
print("Reprise" in playlist)
Output:
3
Solar
Now playing: Intro
Now playing: Solar
Now playing: Nightdrive
True
False
Implementing __len__, __getitem__, and __iter__ turns Playlist into a real container: len(), index access, and for loops all just work. Defining __iter__ explicitly (returning iter(self._songs)) is preferred over relying on the older fallback where Python repeatedly calls __getitem__ with increasing integers until IndexError — that fallback still exists for backward compatibility, but it’s slower and doesn’t support arbitrary iterables. __contains__ is optional; without it, in would still work by falling back to iteration, but defining it directly can be faster or clearer for custom lookup logic.
Example 4: The Context Manager Protocol with __enter__ and __exit__
class ManagedFile:
def __init__(self, filename: str) -> None:
self.filename = filename
def __enter__(self) -> list[str]:
print(f"Opening {self.filename}")
self.lines = ["Chapter 1", "Chapter 2"]
return self.lines
def __exit__(self, exc_type, exc_value, traceback) -> bool:
print(f"Closing {self.filename}")
return False
with ManagedFile("notes.txt") as lines:
for line in lines:
print(line)
Output:
Opening notes.txt
Chapter 1
Chapter 2
Closing notes.txt
The with statement calls __enter__ before the block runs and is guaranteed to call __exit__ afterward, even if an exception is raised inside the block — this is what makes context managers reliable for cleanup (closing files, releasing locks, committing or rolling back transactions). The value __enter__ returns becomes the target of the as clause. __exit__ receives the exception type, value, and traceback if one occurred; returning False (or anything falsy) lets the exception propagate normally, while returning True would suppress it — a powerful but easy-to-misuse feature.
Under the Hood: Step by Step
Understanding exactly what Python does when it evaluates v1 + v2 from Example 2 demystifies operator overloading completely:
- Python evaluates
v1 + v2and looks up the+handler ontype(v1), i.e.Vector.__add__— never on the instancev1itself. - It calls
Vector.__add__(v1, v2). Sinceisinstance(other, Vector)passes, a newVectoris constructed and returned. - If
otherwere not aVector— say,v1 + 5—__add__would return the special singletonNotImplementedinstead of raising an error itself. - Seeing
NotImplemented, Python’s operator machinery does not give up: it tries the reflected method on the other operand’s type,type(5).__radd__(5, v1). Sinceinthas no idea what aVectoris, that also returnsNotImplemented. - Only when both attempts return
NotImplementeddoes Python raiseTypeError: unsupported operand type(s) for +: 'Vector' and 'int'with a clear message. - The same try-then-try-reflected-then-fail sequence applies to
==,<,*, and every other binary operator, which is exactly why returningNotImplementedinstead of raising or blindly returningFalsematters.
This also explains a side effect you might not expect: Python automatically sets __hash__ to None on any class that defines __eq__ without also defining __hash__, because two objects that compare equal must produce the same hash — an invariant Python cannot guarantee unless you provide both together. That is the root cause of Common Mistake #1 below.
Common Mistakes
Mistake 1: Defining __eq__ without __hash__
Once a class defines __eq__, Python assumes you’re changing what equality means, so it disables the inherited __hash__ by setting it to None — making instances unhashable and unusable in sets or as dict keys.
class Point2:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return isinstance(other, Point2) and self.x == other.x and self.y == other.y
points = {Point2(1, 2), Point2(3, 4)}
Running this raises TypeError: unhashable type: 'Point2', because __hash__ was implicitly disabled. The fix is to define __hash__ explicitly, built from the same fields used in __eq__:
class Point2:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return isinstance(other, Point2) and self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
points = {Point2(1, 2), Point2(3, 4), Point2(1, 2)}
print(len(points))
Output:
2
Mistake 2: Forgetting to return a string from __repr__
It’s easy to write the body of __repr__ as an expression and forget the return keyword — the expression is evaluated and discarded, and the method implicitly returns None.
class Money:
def __init__(self, amount):
self.amount = amount
def __repr__(self):
f"${self.amount:.2f}"
print(Money(19.99))
This raises TypeError: __repr__ returned non-string (type NoneType), because Python strictly requires __repr__ (and __str__) to return an actual str. Adding the missing return fixes it:
class Money:
def __init__(self, amount):
self.amount = amount
def __repr__(self):
return f"${self.amount:.2f}"
print(Money(19.99))
Output:
$19.99
Best Practices
- Almost always implement
__repr__— it’s what you see in the debugger, in tracebacks, and in the REPL. Make it unambiguous, ideally something that looks like valid Python that could reconstruct the object. - Only add
__str__when you genuinely need a different, more human-friendly rendering than__repr__; otherwise let it fall back automatically and avoid duplicated logic. - Whenever you define
__eq__on a class whose instances should remain usable in sets or as dict keys, also define a matching__hash__built from the same fields. - Return
NotImplemented(never raise, never returnFalse/Trueblindly) from comparison and arithmetic dunders when the other operand’s type isn’t supported, so Python can try the reflected method or produce a clearTypeError. - Use
functools.total_orderingto derive__le__,__gt__, and__ge__automatically once you’ve written__eq__and one of the ordering methods — it removes repetitive boilerplate. - Keep dunder methods behaving the way Python expects:
__len__must return a non-negativeint,__bool__must return an actualbool, and__hash__must be stable for the lifetime of the object (never hash on mutable fields that can change). - Prefer implementing
__iter__over relying only on__getitem__for iteration — it’s the modern, explicit protocol and works with arbitrary key types, not just integers starting at zero.
Practice Exercises
- Exercise 1: Write a
Fractionclass storing a numerator and denominator that implements__add__,__mul__,__eq__, and__repr__. Reduce the fraction to lowest terms in__init__usingmath.gcd.Fraction(1, 2) + Fraction(1, 3)should print as5/6. - Exercise 2: Write a
Stackclass backed by a list that implements__len__,__bool__(so an empty stack is falsy), andpush/popmethods. Add__repr__soprint(stack)shows its contents. - Exercise 3: Write a
Gridclass wrapping a 2D list that implements__getitem__and__setitem__accepting an(row, col)tuple, so thatgrid[1, 2] = 'X'andgrid[1, 2]both work. Hint: the key passed to__getitem__forgrid[1, 2]is the tuple(1, 2).
Summary
- Magic methods (dunder methods) are how Python’s built-in syntax — operators,
len(),for,with, indexing — dispatches to your own classes. - Implicit dunder lookups happen on the type, not the instance, so assigning a dunder directly on an object doesn’t affect built-in syntax.
__repr__is for developers/debugging;__str__is for end users, and it falls back to__repr__if undefined.- Comparison and arithmetic dunders should return
NotImplementedfor unsupported types so Python can try the reflected method before failing. - Defining
__eq__disables the default__hash__— define both together if instances need to be hashable. __len__,__getitem__,__iter__, and__contains__turn a class into a fully-fledged container.__enter__and__exit__implement the context manager protocol used by thewithstatement, guaranteeing cleanup code runs.
