What you'll learn
Quick Answer
Dunder methods are the hooks Python calls behind ordinary syntax: len(x) calls x.__len__(), x == y calls x.__eq__(y), with x calls x.__enter__(). Implement them and your class works with built-in functions, loops, comparisons and the with statement. The two rules people miss: containers print elements using __repr__, not __str__, and defining __eq__ without __hash__ makes your objects unusable in sets and dict keys.
Why dunder methods exist at all
Python has very little special-case magic for built-in types. When you write len(x), Python does not check whether x is a list. It looks for a method named __len__ on the type and calls it. When you write a + b, it looks for __add__. When you write for item in x, it looks for __iter__.
That is the whole idea. The double underscore names, said aloud as dunder, are the agreed-upon slots for these behaviours. Fill in a slot and your own class starts responding to normal Python syntax instead of demanding a bespoke method with a name only you remember.
class Marksheet:
def __init__(self, student, marks):
self.student = student
self.marks = marks
def __len__(self):
return len(self.marks)
m = Marksheet("Ananya", [78, 65, 91])
print(len(m)) # 3, because len() called m.__len__()Note that you never call m.__len__() yourself. Dunder methods are meant to be invoked by the language, not by your code. Calling them directly works, but it reads badly and skips useful fallbacks that the built-in functions handle for you.
__init__ is the one everybody already knows, and it is worth being precise about it: it is not a constructor. By the time __init__ runs the object already exists; __new__ created it. __init__ only fills in attributes, and it must return None. Writing return self at the end raises TypeError: __init__() should return None, which surprises people coming from other languages.
You do not need to implement dozens of these. In real code, five or six cover almost everything: the two string ones, equality, length, iteration and the context manager pair.
__str__ vs __repr__, and the list trap
This is the one that wastes an afternoon. You define __str__, print an object, and it looks perfect. Then you print a list of them.
class Student:
def __init__(self, name, roll):
self.name = name
self.roll = roll
def __str__(self):
return f"{self.name} ({self.roll})"
s = Student("Ravi", "CS21")
print(s) # Ravi (CS21)
print([s]) # [<__main__.Student object at 0x7f2c...>]Containers never call __str__ on their elements. A list's own __repr__ builds its output by calling repr() on each item, because a container has no idea how you would want its members joined into a sentence. So you get the default object repr, memory address and all.
The division of labour is worth learning properly. __str__ is for the end user: readable, no clutter. __repr__ is for you, the developer: unambiguous, ideally something you could paste back into a REPL to recreate the object. If you only implement one, implement __repr__, because str() falls back to __repr__ when __str__ is missing, but never the other way round.
class Student:
def __init__(self, name, roll):
self.name = name
self.roll = roll
def __repr__(self):
return f"Student({self.name!r}, {self.roll!r})"
print([Student("Ravi", "CS21")])
# [Student('Ravi', 'CS21')]The !r conversion inside an f-string means "use repr of this value", which is what puts the quotes around the strings. It also stops an empty string or a value containing spaces from silently disappearing in your debug output. The same distinction shows up in logging and in debuggers, which is exactly when a clear repr saves you.
__eq__ quietly breaks sets and dicts
By default two objects are equal only if they are the same object in memory. Usually you want value equality instead, so you write __eq__. That works, and it also silently removes your class from every set and dictionary in your program.
class Student:
def __init__(self, roll):
self.roll = roll
def __eq__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.roll == other.roll
print(Student("CS21") == Student("CS21")) # True
set([Student("CS21")])
# TypeError: unhashable type: 'Student'Python does this on purpose. Objects that compare equal must hash equal, otherwise dictionaries break in ways that are impossible to debug. Since Python cannot guess which attributes your new __eq__ considers, it sets __hash__ to None and makes you decide.
Define __hash__ over the same fields your equality uses, and make sure those fields never change while the object is in a set:
def __hash__(self):
return hash(self.roll)Returning NotImplemented rather than False for an unrelated type matters too. It tells Python to try the reflected operation on the other object, so if somebody later writes a Roll class that does know how to compare itself with a Student, that side gets its turn instead of being locked out by your class. When neither side claims the comparison, Python falls back to identity and the answer is False anyway, so you lose nothing by being polite. Note it is the constant NotImplemented, not the NotImplementedError exception.
If all of this feels like paperwork, that is what dataclasses are for. @dataclass writes __init__, __repr__ and __eq__ for you, and @dataclass(frozen=True) additionally makes instances immutable and hashable. For plain data holders it is almost always the right choice, and it removes an entire category of hand-written bugs.
Length, iteration and an accidental falsy object
__len__ and __iter__ are what turn a class into something that feels like a collection. __iter__ must return an iterator, and the simplest way to produce one is to make the method a generator with yield.
class Batch:
def __init__(self, students):
self.students = students
def __len__(self):
return len(self.students)
def __iter__(self):
yield from self.students
def __contains__(self, name):
return name in self.students
b = Batch(["Ravi", "Ananya", "Imran"])
print(len(b)) # 3
for s in b: print(s) # works
print("Imran" in b) # TrueWithout __contains__, the in operator falls back to iterating and comparing, which still works but scans everything. With __getitem__ alone and no __iter__, Python will still iterate by asking for index 0, 1, 2 and so on until IndexError, a legacy protocol that occasionally explains why an unfamiliar class is iterable.
Now the gotcha. Truthiness in Python checks __bool__ first, and if that is missing it falls back to __len__. So the moment you add __len__, an empty instance of your class becomes falsy.
empty = Batch([])
if empty:
print("has students")
else:
print("treated as False") # this runsOften that is exactly what you want. But if your class is, say, a report object that happens to expose __len__ for the number of rows, then if report: now means "if the report has rows", and a valid but empty report gets skipped by code that meant to check for None. The fix is either to write if report is not None: at the call site, or to define __bool__ explicitly and return True.
with statements, calls and operators
__enter__ and __exit__ implement the with statement. Whatever __enter__ returns is what lands after as, and __exit__ runs on the way out whether the block finished normally or raised.
import time
class Timer:
def __init__(self, label):
self.label = label
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
took = time.perf_counter() - self.start
print(f"{self.label} took {took:.3f}s")
return False # do not swallow exceptions
with Timer("query"):
sum(range(1_000_000))That return False is load bearing. If __exit__ returns a truthy value, Python treats the exception as handled and swallows it. Returning True from a cleanup method you wrote for logging will silently hide real errors from the rest of your program, and the bug looks like "the code just stopped doing anything". Returning None, which is what happens if you write no return statement, is falsy and therefore safe.
A few others earn their place. __call__ makes an instance callable, which is how objects can stand in for functions and how decorators are sometimes written as classes. __getitem__ and __setitem__ give you obj[key] syntax. __add__, __lt__ and friends power arithmetic and sorting; implement __lt__ and sorted() works on your objects with no key function.
class Fee:
def __init__(self, rupees):
self.rupees = rupees
def __add__(self, other):
return Fee(self.rupees + other.rupees)
def __lt__(self, other):
return self.rupees < other.rupees
def __repr__(self):
return f"Fee({self.rupees})"
print(sorted([Fee(499), Fee(199), Fee(999)]))
# [Fee(199), Fee(499), Fee(999)]The discipline that keeps this from becoming clever nonsense is simple: only implement a dunder when the built-in syntax would mean the obvious thing to a reader. Overloading + to send an email is how a codebase becomes unmaintainable.
