The Data Model & Dunder Methods
Why everything is an object and how the special methods (__len__, __iter__, __eq__ …) let your own types plug into the language.
Python feels small because the surface area is consistent. len(x), for item in x, x[0], x == y, with x: and even x() all route through the same idea: objects implement protocols, and syntax calls those protocols for you.
The data model is the contract behind that syntax. Once you can read it, built-in containers stop feeling special, and your own classes can behave like native Python without helper methods like .to_string() or .equals().
The mental model
Everything in Python is an object: integers, functions, classes, modules, exceptions, iterators. Objects have identity, type, and behavior. The behavior Python’s syntax knows about is exposed through dunder methods — names with double underscores on both sides.
class Team:
def __init__(self, members):
self._members = list(members)
def __len__(self):
return len(self._members)
def __iter__(self):
return iter(self._members)
team = Team(["Ada", "Grace", "Katherine"])
len(team) # calls team.__len__()
list(team) # calls team.__iter__()
You usually do not call dunders yourself. You implement them so Python can call them at the right time. That keeps the public API idiomatic: callers write len(team), not team.length().
Built-in len() delegates to the object's __len__. Return an int.
Representation: __repr__ and __str__
__repr__ is for developers. It should be unambiguous and, when practical, look like something you could paste into Python to rebuild the object. __str__ is for users. It can be friendlier.
class Job:
def __init__(self, name, status):
self.name = name
self.status = status
def __repr__(self):
return f"Job(name={self.name!r}, status={self.status!r})"
def __str__(self):
return f"{self.name}: {self.status}"
job = Job("embed-docs", "running")
repr(job) # "Job(name='embed-docs', status='running')"
str(job) # "embed-docs: running"
If you only implement one, implement __repr__. Debuggers, logs, notebooks, and failed assertions lean on it constantly.
Equality and hashing travel together
__eq__ defines value equality. __hash__ lets an object live in a set or be a dict key. The rule is non-negotiable: if a == b, then hash(a) == hash(b) for as long as either object is used as a key.
That is why mutable objects are dangerous keys. If a field used in the hash changes after insertion, the object is now stored in the wrong bucket.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
Only write __hash__ when the fields it depends on are effectively immutable. For value objects, @dataclass(frozen=True) is often the cleanest option.
Containers: __len__, __iter__, and __getitem__
A container does not need to inherit from list to feel list-like. Implement the protocols you actually support:
__len__powerslen(obj)and truthiness when__bool__is absent.__iter__powersfor x in obj, unpacking,list(obj),sum(obj), and most standard-library consumers.__getitem__powers indexing and slicing withobj[i].
class Batch:
def __init__(self, rows):
self._rows = tuple(rows)
def __len__(self):
return len(self._rows)
def __iter__(self):
return iter(self._rows)
def __getitem__(self, index):
return self._rows[index]
batch = Batch(["a", "b", "c"])
batch[1] # "b"
first, *rest = batch
Favor delegation over cleverness: if your class wraps a tuple, let the tuple do the indexing, slicing, and iteration work.
Callable objects and context managers
__call__ lets an instance behave like a function while keeping configuration or state on the object.
class Prefixer:
def __init__(self, prefix):
self.prefix = prefix
def __call__(self, text):
return f"{self.prefix}{text}"
add_error = Prefixer("ERROR: ")
add_error("disk full") # "ERROR: disk full"
Context managers use __enter__ and __exit__ to guarantee setup and cleanup around a block. Files, locks, database transactions, temporary config, tracing spans — they all use this protocol.
class Section:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"start {self.name}")
return self
def __exit__(self, exc_type, exc, tb):
print(f"end {self.name}")
return False # propagate exceptions
with Section("indexing"):
build_index()
Returning True from __exit__ suppresses the exception. Almost always, return False or None so failures remain visible.
When to reach for this
Variations
Try it
These are not puzzles. They are the small protocol decisions that make production objects pleasant to inspect, compare, and compose.
Implement a small Vector type
Create a 2D Vector that supports a + b, compares by value, and prints as Vector(1, 2) in a debugger. Return NotImplemented when adding a non-vector so Python has a chance to try the other operand or raise the right error.
Approach. Store immutable coordinates, implement __repr__ for debugging, __eq__ for value comparison, and __add__ for native + syntax.
Show solution
class Vector:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Vector({self.x!r}, {self.y!r})"
def __eq__(self, other: object):
if not isinstance(other, Vector):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __add__(self, other: object):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
a = Vector(1, 2)
b = Vector(3, 4)
assert a + b == Vector(4, 6)The important part is not the arithmetic; it is the contract. repr(a) is useful in failures, == compares values instead of identity, and unsupported operands fail cleanly instead of raising an unrelated AttributeError.
Write a timer context manager two ways
Build a timer that can wrap a block with with and expose the elapsed seconds afterward. Implement it once as a class with __enter__ / __exit__, then once with contextlib.contextmanager.
Approach. The class version stores state on self. The generator version puts setup before yield and cleanup in finally, which is exactly the context manager lifecycle in a smaller form.
Show solution
from contextlib import contextmanager
from time import perf_counter
class Timer:
def __enter__(self):
self.started_at = perf_counter()
self.elapsed = 0.0
return self
def __exit__(self, exc_type, exc, tb):
self.elapsed = perf_counter() - self.started_at
return False
@contextmanager
def timer():
state = {"elapsed": 0.0}
started_at = perf_counter()
try:
yield state
finally:
state["elapsed"] = perf_counter() - started_at
with Timer() as t:
do_work()
print(t.elapsed)
with timer() as t:
do_work()
print(t["elapsed"])Both versions guarantee cleanup even if do_work() raises. Use the class when callers benefit from attributes or helper methods; use contextmanager when the lifecycle is just a few lines around a yield.
Explain the equality/hash gotcha
This class compares users by username. Why can you not put instances in a set, and what is the safe fix?
class User:
def __init__(self, username):
self.username = username
def __eq__(self, other):
return isinstance(other, User) and self.username == other.username
users = {User("ada")}Approach. Once you override __eq__, Python disables the inherited identity hash because it would violate the rule that equal objects need equal hashes. Make the value immutable, then let Python generate a compatible hash.
Show solution
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
username: str
users = {User("ada")}
assert User("ada") in usersThe frozen dataclass generates __eq__ and a stable __hash__ from the same field. Do not hash mutable fields. If username could change after insertion, the set would look in the wrong hash bucket and membership would become broken.