Dunder methods and the data model
len(x), x + y, print(x), and for item in x all call a method on x under the hood — the "data model" is just the fixed set of method names Python looks for when you use its built-in syntax.
4 min read
The pattern behind Python's built-in syntax
len([1, 2, 3]) # calls [1, 2, 3].__len__()
[1, 2] + [3, 4] # calls [1, 2].__add__([3, 4])
print(some_object) # calls str(some_object), which calls some_object.__str__()
for x in some_list: # calls iter(some_list), which calls some_list.__iter__()len(), +, print(), and for aren't hardcoded to work only on lists and strings — each one is really just calling a specific method with a specific double-underscore ("dunder") name on the object it's given. len(obj) is Python's way of saying obj.__len__(); a + b is Python's way of saying a.__add__(b). This is Python's data model: a fixed set of method names that built-in syntax and functions look for, so any class implementing the right dunder methods gets to participate in that syntax, not just the built-in types.
__repr__ and __str__ — how an object describes itself
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(3, 4)
print(p) # <__main__.Point object at 0x7f...> — not useful at allWithout __repr__ or __str__, printing a custom object shows its memory address, which is almost never what you want to see. Implementing one fixes that:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point({self.x}, {self.y})"
p = Point(3, 4)
print(p) # Point(3, 4)
[p] # [Point(3, 4)] — repr() is also what's shown inside a list/dict__repr__ is meant to be an unambiguous, ideally code-like representation, primarily for developers reading logs or a debugger — str() falls back to __repr__ automatically if __str__ isn't defined, which is why implementing just __repr__ alone already fixes both print(p) and how p looks inside a list. __str__, when it is defined separately, is meant for a more human-readable, end-user-facing display — the distinction matters less for simple objects and more once a class needs genuinely different debug output versus user-facing output.
__eq__ — what == actually means for a custom class
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
Point(1, 2) == Point(1, 2) # False! — without __eq__, == compares identity, not valuesWithout __eq__, Python's default == falls back to identity comparison (the same thing is checks) — two separately-constructed Point objects with identical x/y are still two different objects in memory, so == says they're unequal, which usually isn't what anyone actually wants for a value-like class:
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x and self.y == other.y
Point(1, 2) == Point(1, 2) # True — now compares valuesThe isinstance check matters: without it, Point(1, 2) == "not a point" would raise an AttributeError trying to read other.x on a string, instead of correctly returning False.
__len__ and __getitem__ — participating in container syntax
class Playlist:
def __init__(self, songs):
self.songs = songs
def __len__(self):
return len(self.songs)
def __getitem__(self, index):
return self.songs[index]
playlist = Playlist(["a", "b", "c"])
len(playlist) # 3 — calls __len__
playlist[0] # "a" — calls __getitem__
for song in playlist: # __getitem__ alone is enough to make this work too
print(song)Implementing __getitem__ alone is enough to make a class support both indexing (playlist[0]) and iteration (for song in playlist) — if a class has no __iter__, Python falls back to calling __getitem__ with increasing integers (0, 1, 2, ...) until it raises IndexError. This is a real, if slightly old-fashioned, way objects become iterable without formally implementing the full iterator protocol from the generators lesson.
__enter__ and __exit__ — the context manager lesson, restated
The context managers lesson already covered this pair in depth: implementing __enter__/__exit__ is what makes a class usable with with. It's included here specifically to make the pattern explicit — with obj: is exactly the same kind of dunder-method dispatch as len(obj) or obj + other, just for a different piece of syntax.
The actual principle underneath all of it
Every one of these — __repr__, __eq__, __len__, __getitem__, __enter__/__exit__, and many more (__lt__ for <, __contains__ for in, __call__ for calling an instance like a function) — is the same idea applied to a different piece of syntax: Python's built-in operators and functions are implemented in terms of method calls on the objects involved, and any class can opt into that syntax by implementing the matching dunder method. This is also precisely what "duck typing" means in Python at a mechanical level — code that does len(x) doesn't care whether x is a list, a string, or a custom Playlist, only that x.__len__() exists and returns something sensible.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What does len(obj) actually do under the hood?
2. Why does implementing only __repr__ (and not __str__) usually fix both print(obj) and how obj looks inside a list?
3. Without a custom __eq__, why does Point(1, 2) == Point(1, 2) evaluate to False?
4. Why does implementing __getitem__ alone (without __iter__) make a class work in a for loop?