Codetail

Article 7 of 8

Magic Methods

Make your objects feel like Python built them.

25 min read

__repr__ and __str__

Without a __repr__, printing your object gives you something useless:

Python
1class Point:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6p = Point(3, 4)
7print(p) # <__main__.Point object at 0x0000...>

That tells you nothing useful. __repr__ is the method Python calls when it needs to represent an object as a string for developers: in the REPL, in debuggers, in log output, and when you call repr(). __str__ is the human-readable version, used by print() and str().

Python
1class Point:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def __repr__(self):
7 return f"Point({self.x}, {self.y})"
8
9 def __str__(self):
10 return f"({self.x}, {self.y})"
11
12p = Point(3, 4)
13
14print(repr(p)) # Point(3, 4) -- developer view
15print(str(p)) # (3, 4) -- user view
16print(p) # (3, 4) -- print() uses __str__
17
18points = [Point(1, 2), Point(3, 4)]
19print(points) # [Point(1, 2), Point(3, 4)] -- list uses __repr__

Rule: always implement __repr__. If you only need one, it is this one. When __str__ is not defined, Python falls back to __repr__. The reverse is not true.

__eq__ and __hash__: the pair you must keep together

By default, == checks identity: are these the same object in memory? Two separate objects with identical data are not equal:

Python
1class Point:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6a = Point(3, 4)
7b = Point(3, 4)
8
9print(a == b) # False! Same data, different objects
10print(a is b) # False, obviously different objects

Implement __eq__ to define what equality means for your class:

Python
1class Point:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def __eq__(self, other):
7 if not isinstance(other, Point):
8 return NotImplemented
9 return self.x == other.x and self.y == other.y
10
11a = Point(3, 4)
12b = Point(3, 4)
13c = Point(1, 2)
14
15print(a == b) # True
16print(a == c) # False
17print(a == "not a point") # False, not NotImplemented raised

The gotcha: defining __eq__ silently breaks __hash__

When you define __eq__, Python automatically sets __hash__ to None. This makes your object unhashable. You cannot use it in a set or as a dict key.

Python
1# With only __eq__ defined:
2a = Point(3, 4)
3s = {a} # TypeError

Always define __hash__ alongside __eq__. The rule: objects that compare equal must have the same hash.

Python
1class Point:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def __eq__(self, other):
7 if not isinstance(other, Point):
8 return NotImplemented
9 return self.x == other.x and self.y == other.y
10
11 def __hash__(self):
12 return hash((self.x, self.y)) # tuple hash is stable and correct
13
14a = Point(3, 4)
15b = Point(3, 4)
16
17print(a == b) # True
18print(hash(a) == hash(b)) # True
19print({a, b}) # {Point(3, 4)} -- deduplicated in a set

Operator overloading

When you write a + b, Python calls a.__add__(b). When you write a < b, Python calls a.__lt__(b). You can define these methods on your own classes to make operators work naturally.

Python
1class Vector:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def __repr__(self):
7 return f"Vector({self.x}, {self.y})"
8
9 def __add__(self, other):
10 return Vector(self.x + other.x, self.y + other.y)
11
12 def __sub__(self, other):
13 return Vector(self.x - other.x, self.y - other.y)
14
15 def __mul__(self, scalar):
16 return Vector(self.x * scalar, self.y * scalar)
17
18 def __abs__(self):
19 return (self.x ** 2 + self.y ** 2) ** 0.5
20
21v1 = Vector(1, 2)
22v2 = Vector(3, 4)
23
24print(v1 + v2) # Vector(4, 6)
25print(v2 - v1) # Vector(2, 2)
26print(v1 * 3) # Vector(3, 6)
27print(abs(v2)) # 5.0

Reflected operators

If you write 3 * v1 instead of v1 * 3, Python first tries (3).__mul__(v1). That returns NotImplemented because integers do not know about vectors. Python then tries the reflected version: v1.__rmul__(3).

Python
1class Vector:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def __repr__(self):
7 return f"Vector({self.x}, {self.y})"
8
9 def __mul__(self, scalar):
10 return Vector(self.x * scalar, self.y * scalar)
11
12 def __rmul__(self, scalar): # 3 * v calls this
13 return self.__mul__(scalar)
14
15v = Vector(1, 2)
16print(v * 3) # Vector(3, 6) -- uses __mul__
17print(3 * v) # Vector(3, 6) -- uses __rmul__

Common operator methods: arithmetic (__add__, __sub__, __mul__, __truediv__, __mod__, __pow__), comparison (__lt__, __le__, __gt__, __ge__), and bitwise (__and__, __or__, __xor__). Only implement the ones that make semantic sense for your class.

The container protocol

When you write len(x), Python calls x.__len__(). When you write x[key], Python calls x.__getitem__(key). When you write for item in x, Python calls x.__iter__(). Implement these and your object behaves like a built-in container.

Python
1class Stack:
2 def __init__(self):
3 self._items = []
4
5 def push(self, item):
6 self._items.append(item)
7
8 def pop(self):
9 return self._items.pop()
10
11 def __len__(self):
12 return len(self._items)
13
14 def __getitem__(self, index):
15 return self._items[index]
16
17 def __iter__(self):
18 return iter(self._items)
19
20 def __contains__(self, item): # 'in' operator
21 return item in self._items
22
23 def __repr__(self):
24 return f"Stack({self._items})"
25
26s = Stack()
27s.push(10)
28s.push(20)
29s.push(30)
30
31print(len(s)) # 3
32print(s[0]) # 10
33print(s[-1]) # 30
34
35for item in s:
36 print(item) # 10, 20, 30
37
38print(20 in s) # True
39print(99 in s) # False

Once your class implements __iter__, you also get list comprehensions and unpacking for free:

Python
1s = Stack()
2s.push(1)
3s.push(2)
4s.push(3)
5
6doubled = [x * 2 for x in s]
7print(doubled) # [2, 4, 6]
8
9a, b, c = s # unpacking works
10print(a, b, c) # 1 2 3

Context managers: __enter__ and __exit__

The with statement is one of Python's best features. It guarantees cleanup runs even if an exception occurs. Under the hood, it calls two methods: __enter__ when entering the block, and __exit__ when leaving, whether normally or via exception.

Python
1import time
2
3class Timer:
4 def __enter__(self):
5 self._start = time.perf_counter()
6 return self # this becomes the 'as' value
7
8 def __exit__(self, exc_type, exc_val, exc_tb):
9 elapsed = time.perf_counter() - self._start
10 print(f"Elapsed: {elapsed:.4f}s")
11 return False # False means: don't suppress exceptions
12
13with Timer() as t:
14 total = sum(range(1_000_000))
15 print(f"Sum: {total}")

__exit__ receives three arguments about any exception that occurred: exc_type, exc_val, exc_tb. If no exception occurred, all three are None. Return True to suppress the exception, or False (or nothing) to let it propagate.

Python
1class ManagedFile:
2 def __init__(self, path, mode="r"):
3 self.path = path
4 self.mode = mode
5 self._file = None
6
7 def __enter__(self):
8 self._file = open(self.path, self.mode)
9 return self._file
10
11 def __exit__(self, exc_type, exc_val, exc_tb):
12 if self._file:
13 self._file.close()
14 return False # let exceptions propagate
15
16with ManagedFile("data.txt") as f:
17 content = f.read()
18# f is closed here, even if read() raised an exception

You do not need to implement this from scratch when contextlib.contextmanager offers a simpler decorator-based approach for straightforward cases. But understanding the dunder protocol helps you recognize what any context manager is doing under the hood.

Any object with __enter__ and __exit__ works with with. No inheritance required. This is duck typing in action.