The Python data model, defined in the Python Language Reference, specifies the special methods — named with double underscores ("dunders") — that the interpreter calls to implement built-in operations. Implementing these methods lets custom objects support syntax like len(obj), obj[key], obj + other, and for x in obj.
The methods behind the syntax
What you will learn: that dunder methods like __len__ and __add__ are hooks Python calls for built-in syntax, and the difference between __repr__ and __str__.
How to read this tab: You never call dunders directly — you use the syntax and Python calls them for you.
When you write len(x), Python actually calls x.__len__(). When you write a + b, Python calls a.__add__(b). These double-underscore methods are hooks. Implement them on your own class and your objects work with Python's built-in syntax as if they were native types.
You use the syntax; Python calls the dunder. Write len(x), not x.__len__(). Write print(obj), not obj.__str__(). The dunders are the interpreter’s hooks — implement them on your class and your objects work with all of Python’s built-in syntax.
"Dunder" methods are hooks
Dunder (double-underscore) methods are how Python's syntax maps onto method calls. You rarely call them directly — instead you use the syntax, and Python calls the dunder for you.
class Money:
def __init__(self, rupees): # called by Money(100)
self.rupees = rupees
def __repr__(self): # called by repr() and the REPL
return f"Money({self.rupees})"
def __str__(self): # called by str() and print()
return f"₹{self.rupees:,}"
def __eq__(self, other): # called by ==
return self.rupees == other.rupees
def __lt__(self, other): # called by <
return self.rupees < other.rupees
m = Money(1500)
print(m) # ₹1,500 — __str__
print(repr(m)) # Money(1500) — __repr__
print(Money(100) == Money(100)) # True — __eq__
print(Money(50) < Money(80)) # True — __lt__
# You never call m.__str__() yourself — print() does it for you.__str__ is for humans (what print() shows). __repr__ is for developers (what the REPL and debuggers show) and should ideally be valid Python that recreates the object. If you only write one, write __repr__ — Python falls back to it when __str__ is missing.
✅ Beginner tab complete
- I know len(x) calls x.__len__() and a+b calls a.__add__(b)
- I can implement __init__, __repr__, __str__, __eq__
- I know __repr__ is for developers and __str__ is for humans
- I know to write __repr__ if I write only one of them
Container protocol and operator overloading
What you will learn: the container dunders (__len__, __getitem__, __contains__), operator overloading (__add__, __mul__), and callable objects (__call__).
How to read this tab: Implementing __getitem__ alone makes an object iterable — Python calls it 0,1,2... until IndexError.
__getitem__ alone gives you iteration for free. If a class implements __getitem__ for integer indices from 0 up, Python’s for loop works by calling it with 0, 1, 2... until IndexError — even without __iter__. Implement __len__, __getitem__, __contains__ to behave like a real container.
The container protocol
Implementing a handful of dunders makes your object behave like a built-in container — supporting len(), indexing, membership tests, and iteration.
class Playlist:
def __init__(self, songs):
self._songs = list(songs)
def __len__(self): # len(playlist)
return len(self._songs)
def __getitem__(self, index): # playlist[0], playlist[1:3], and iteration
return self._songs[index]
def __setitem__(self, index, value): # playlist[0] = "new"
self._songs[index] = value
def __contains__(self, song): # "song" in playlist
return song in self._songs
p = Playlist(["Song A", "Song B", "Song C"])
print(len(p)) # 3 — __len__
print(p[0]) # Song A — __getitem__
print(p[1:]) # ['Song B', 'Song C'] — slicing via __getitem__
print("Song B" in p) # True — __contains__
p[0] = "New Song" # __setitem__
# __getitem__ alone is enough to make it iterable!
for song in p: # Python calls __getitem__ with 0, 1, 2... until IndexError
print(song)Defining __eq__ disables __hash__. If you write __eq__, Python sets __hash__ to None and instances become unhashable (no dict keys, no sets). Add __hash__ back, or use @dataclass(frozen=True) which handles both correctly.
Operator overloading and callable objects
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other): # self + other
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other): # self - other
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar): # self * scalar
return Vector(self.x * scalar, self.y * scalar)
def __abs__(self): # abs(self)
return (self.x ** 2 + self.y ** 2) ** 0.5
def __bool__(self): # bool(self), truthiness
return bool(self.x or self.y)
v1 = Vector(2, 3)
v2 = Vector(1, 1)
print(v1 + v2) # Vector(3, 4)
print(v1 * 3) # Vector(6, 9)
print(abs(Vector(3, 4))) # 5.0
print(bool(Vector(0, 0))) # False
# __call__ makes an instance callable like a function
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x): # instance(x)
return x * self.factor
triple = Multiplier(3)
print(triple(10)) # 30 — the instance behaves like a function__eq__, Python sets __hash__ to None, making instances unhashable (unusable as dict keys / set members). Add __hash__ back, or use @dataclass(frozen=True) which handles both.__getitem__ (accepting integer indices from 0 upward) but not __iter__, Python's for loop still works by calling __getitem__ with 0, 1, 2... until IndexError.✅ Intermediate tab complete
- I can make a class support len(), indexing, and "in"
- I can overload +, -, * with __add__, __sub__, __mul__
- I can make an instance callable with __call__
- I know defining __eq__ disables __hash__ unless I add it back
Reflected operators, __new__, attribute hooks
What you will learn: reflected operators (__radd__/__rmul__), __new__ vs __init__, and the attribute-access hooks __getattr__/__setattr__.
How to read this tab: The data model unifies context managers, iterators, descriptors, and numeric coercion into one consistent set of protocols.
Reflected operators, __new__, and attribute hooks
The data model includes reflected (right-hand) operator methods, called when the left operand does not know how to handle the operation. a + b first tries a.__add__(b); if that returns NotImplemented, Python tries b.__radd__(a). This is how 2 * vector can work even though int has no idea what a Vector is — Python falls back to vector.__rmul__(2).
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __mul__(self, scalar): # vector * 2
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar): # 2 * vector (reflected)
return self.__mul__(scalar)
v = Vector(1, 2)
# print((2 * v)) # works via __rmul__ since int.__mul__(v) returns NotImplemented
# __new__ vs __init__ — creation vs initialisation
class Singleton:
_instance = None
def __new__(cls): # __new__ CREATES the instance
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self): # __init__ INITIALISES it (may run repeatedly)
pass
print(Singleton() is Singleton()) # True — same instance
# Attribute access hooks
class Proxy:
def __getattr__(self, name): # called ONLY when normal lookup fails
return f"no attribute named {name}"
def __setattr__(self, name, value):# called on EVERY attribute assignment
print(f"setting {name} = {value}")
super().__setattr__(name, value)
p = Proxy()
p.foo = 10 # prints: setting foo = 10
print(p.missing) # "no attribute named missing"
# Context manager protocol — __enter__ / __exit__
# Iterator protocol — __iter__ / __next__
# Descriptor protocol — __get__ / __set__ / __delete__
# These are all part of the data model too.The data model is the unifying theory behind much of Python: context managers (__enter__/__exit__), iterators (__iter__/__next__), descriptors (__get__/__set__), numeric coercion, attribute access, and object creation (__new__ before __init__) are all specified there. Learning to read the data model chapter of the Language Reference is what turns "Python has a lot of magic" into "Python has a small set of consistent protocols."
✅ Expert tab complete
- I know 2*vector works via vector.__rmul__ when int.__mul__ returns NotImplemented
- I know __new__ creates the instance and __init__ initialises it
- I know __getattr__ is called only when normal lookup fails
- I can see how the data model unifies most of Python’s "magic"