__repr__ and __str__
Without a __repr__, printing your object gives you something useless:
1class Point:2 def __init__(self, x, y):3 self.x = x4 self.y = y56p = 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().
1class Point:2 def __init__(self, x, y):3 self.x = x4 self.y = y56 def __repr__(self):7 return f"Point({self.x}, {self.y})"89 def __str__(self):10 return f"({self.x}, {self.y})"1112p = Point(3, 4)1314print(repr(p)) # Point(3, 4) -- developer view15print(str(p)) # (3, 4) -- user view16print(p) # (3, 4) -- print() uses __str__1718points = [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:
1class Point:2 def __init__(self, x, y):3 self.x = x4 self.y = y56a = Point(3, 4)7b = Point(3, 4)89print(a == b) # False! Same data, different objects10print(a is b) # False, obviously different objects
Implement __eq__ to define what equality means for your class:
1class Point:2 def __init__(self, x, y):3 self.x = x4 self.y = y56 def __eq__(self, other):7 if not isinstance(other, Point):8 return NotImplemented9 return self.x == other.x and self.y == other.y1011a = Point(3, 4)12b = Point(3, 4)13c = Point(1, 2)1415print(a == b) # True16print(a == c) # False17print(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.
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.
1class Point:2 def __init__(self, x, y):3 self.x = x4 self.y = y56 def __eq__(self, other):7 if not isinstance(other, Point):8 return NotImplemented9 return self.x == other.x and self.y == other.y1011 def __hash__(self):12 return hash((self.x, self.y)) # tuple hash is stable and correct1314a = Point(3, 4)15b = Point(3, 4)1617print(a == b) # True18print(hash(a) == hash(b)) # True19print({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.
1class Vector:2 def __init__(self, x, y):3 self.x = x4 self.y = y56 def __repr__(self):7 return f"Vector({self.x}, {self.y})"89 def __add__(self, other):10 return Vector(self.x + other.x, self.y + other.y)1112 def __sub__(self, other):13 return Vector(self.x - other.x, self.y - other.y)1415 def __mul__(self, scalar):16 return Vector(self.x * scalar, self.y * scalar)1718 def __abs__(self):19 return (self.x ** 2 + self.y ** 2) ** 0.52021v1 = Vector(1, 2)22v2 = Vector(3, 4)2324print(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).
1class Vector:2 def __init__(self, x, y):3 self.x = x4 self.y = y56 def __repr__(self):7 return f"Vector({self.x}, {self.y})"89 def __mul__(self, scalar):10 return Vector(self.x * scalar, self.y * scalar)1112 def __rmul__(self, scalar): # 3 * v calls this13 return self.__mul__(scalar)1415v = 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.
1class Stack:2 def __init__(self):3 self._items = []45 def push(self, item):6 self._items.append(item)78 def pop(self):9 return self._items.pop()1011 def __len__(self):12 return len(self._items)1314 def __getitem__(self, index):15 return self._items[index]1617 def __iter__(self):18 return iter(self._items)1920 def __contains__(self, item): # 'in' operator21 return item in self._items2223 def __repr__(self):24 return f"Stack({self._items})"2526s = Stack()27s.push(10)28s.push(20)29s.push(30)3031print(len(s)) # 332print(s[0]) # 1033print(s[-1]) # 303435for item in s:36 print(item) # 10, 20, 303738print(20 in s) # True39print(99 in s) # False
Once your class implements __iter__, you also get list comprehensions and unpacking for free:
1s = Stack()2s.push(1)3s.push(2)4s.push(3)56doubled = [x * 2 for x in s]7print(doubled) # [2, 4, 6]89a, b, c = s # unpacking works10print(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.
1import time23class Timer:4 def __enter__(self):5 self._start = time.perf_counter()6 return self # this becomes the 'as' value78 def __exit__(self, exc_type, exc_val, exc_tb):9 elapsed = time.perf_counter() - self._start10 print(f"Elapsed: {elapsed:.4f}s")11 return False # False means: don't suppress exceptions1213with 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.
1class ManagedFile:2 def __init__(self, path, mode="r"):3 self.path = path4 self.mode = mode5 self._file = None67 def __enter__(self):8 self._file = open(self.path, self.mode)9 return self._file1011 def __exit__(self, exc_type, exc_val, exc_tb):12 if self._file:13 self._file.close()14 return False # let exceptions propagate1516with 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.