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: 5len(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:

  1. Python evaluates v1 + v2 and looks up the + handler on type(v1), i.e. Vector.__add__ — never on the instance v1 itself.
  2. It calls Vector.__add__(v1, v2). Since isinstance(other, Vector) passes, a new Vector is constructed and returned.
  3. If other were not a Vector — say, v1 + 5__add__ would return the special singleton NotImplemented instead of raising an error itself.
  4. 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). Since int has no idea what a Vector is, that also returns NotImplemented.
  5. Only when both attempts return NotImplemented does Python raise TypeError: unsupported operand type(s) for +: 'Vector' and 'int' with a clear message.
  6. The same try-then-try-reflected-then-fail sequence applies to ==, <, *, and every other binary operator, which is exactly why returning NotImplemented instead of raising or blindly returning False matters.

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 return False/True blindly) from comparison and arithmetic dunders when the other operand’s type isn’t supported, so Python can try the reflected method or produce a clear TypeError.
  • Use functools.total_ordering to 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-negative int, __bool__ must return an actual bool, 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 Fraction class storing a numerator and denominator that implements __add__, __mul__, __eq__, and __repr__. Reduce the fraction to lowest terms in __init__ using math.gcd. Fraction(1, 2) + Fraction(1, 3) should print as 5/6.
  • Exercise 2: Write a Stack class backed by a list that implements __len__, __bool__ (so an empty stack is falsy), and push/pop methods. Add __repr__ so print(stack) shows its contents.
  • Exercise 3: Write a Grid class wrapping a 2D list that implements __getitem__ and __setitem__ accepting an (row, col) tuple, so that grid[1, 2] = 'X' and grid[1, 2] both work. Hint: the key passed to __getitem__ for grid[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 NotImplemented for 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 the with statement, guaranteeing cleanup code runs.