The Python Data Model and Dunder Protocols
The Python Data Model and Dunder Protocols
After reading this chapter, you will master Python’s special method protocols (known as “dunder” methods for double underscore), implement unambiguous string representations (__repr__ vs __str__), enable custom objects for dictionary hashing and equality, implement sequence and container behaviors, and make objects callable like functions.
Mental model
The Python Data Model is the API of the Python language itself. Built-in functions, operators, and syntax do not perform hardcoded checks; they delegate to special methods implemented on your classes:
Syntax / Built-in Dunder Method Invoked
────────────────────────────────────────────────────
repr(obj) ──▶ obj.__repr__()
str(obj), print(obj) ──▶ obj.__str__() (falls back to __repr__)
len(obj) ──▶ obj.__len__()
item in obj ──▶ obj.__contains__(item)
obj[key] ──▶ obj.__getitem__(key)
obj == other ──▶ obj.__eq__(other)
hash(obj) ──▶ obj.__hash__()
obj(arg1, arg2) ──▶ obj.__call__(arg1, arg2)
By implementing these protocols, custom classes behave with the same ergonomics and speed as Python’s native data types.
Minimal example
Save as data_model_protocols.py:
# data_model_protocols.py
from typing import Any
class PacketBuffer:
def __init__(self, packets: list[bytes]) -> None:
self._packets = list(packets)
def __len__(self) -> int:
"""Enables len(buffer)."""
return len(self._packets)
def __getitem__(self, index: int | slice) -> Any:
"""Enables buffer[i] indexing and buffer[start:stop] slicing."""
return self._packets[index]
def __contains__(self, item: bytes) -> bool:
"""Enables 'payload in buffer' membership testing."""
return item in self._packets
def __repr__(self) -> str:
"""Unambiguous representation for debugging and logs."""
return f"PacketBuffer(count={len(self._packets)})"
def main() -> None:
p1 = b"\x00\x01\x02\x03"
p2 = b"\xaa\xbb\xcc\xdd"
p3 = b"\xff\xff\xff\xff"
buf = PacketBuffer([p1, p2, p3])
print("Buffer representation:", repr(buf))
print(f"Total packet count : {len(buf)}")
print(f"Packet at index 1 : {buf[1].hex()}")
print(f"Contains p2? : {p2 in buf}")
print(f"Contains missing? : {b'dummy' in buf}")
# Iteration works automatically via __getitem__!
print("Iterating over packet hex strings:")
for pkt in buf:
print(f" - {pkt.hex()}")
if __name__ == "__main__":
main()Run via uv run python data_model_protocols.py:
Buffer representation: PacketBuffer(count=3)
Total packet count : 3
Packet at index 1 : aabbccdd
Contains p2? : True
Contains missing? : False
Iterating over packet hex strings:
- 00010203
- aabbccdd
- ffffffff
Notice that we did not even implement __iter__(): Python’s iteration protocol automatically falls back to __getitem__() starting at index 0 until IndexError is raised!
Worked examples
Case 1: The Golden Rule of __repr__ vs __str__
__repr__: Intended for developers, debuggers, and logs. It should be unambiguous and, whenever practical, look like valid Python code that recreates the object (eval(repr(x)) == x).__str__: Intended for end-user display (print(x)).
# repr_vs_str.py
class Endpoint:
def __init__(self, host: str, port: int, use_tls: bool = True) -> None:
self.host = host
self.port = port
self.use_tls = use_tls
def __repr__(self) -> str:
# Developer-facing, unambiguous
return f"Endpoint(host={self.host!r}, port={self.port}, use_tls={self.use_tls})"
def __str__(self) -> str:
# User-facing URI string
scheme = "https" if self.use_tls else "http"
return f"{scheme}://{self.host}:{self.port}"
if __name__ == "__main__":
ep = Endpoint("api.internal", 8443, use_tls=True)
print("User facing (str) :", str(ep))
print("Developer facing (repr):", repr(ep))Run:
uv run python repr_vs_str.pyOutput:
User facing (str) : https://api.internal:8443
Developer facing (repr): Endpoint(host='api.internal', port=8443, use_tls=True)
Case 2: Equality (__eq__) and Hashing (__hash__)
To allow custom objects to be used as dictionary keys or stored in sets, you must define both __eq__ and __hash__. An object’s hash must be computed from immutable attributes:
# hashable_host.py
class HostID:
def __init__(self, cluster: str, node_id: int) -> None:
self._cluster = cluster
self._node_id = node_id
@property
def cluster(self) -> str:
return self._cluster
@property
def node_id(self) -> int:
return self._node_id
def __eq__(self, other: object) -> bool:
if not isinstance(other, HostID):
return NotImplemented
return self._cluster == other._cluster and self._node_id == other._node_id
def __hash__(self) -> int:
# Hash a tuple of the immutable identity attributes
return hash((self._cluster, self._node_id))
def __repr__(self) -> str:
return f"HostID({self._cluster!r}, {self._node_id})"
if __name__ == "__main__":
h1 = HostID("prod-us", 101)
h2 = HostID("prod-us", 101)
h3 = HostID("prod-eu", 101)
print(f"h1 == h2 : {h1 == h2}")
print(f"h1 == h3 : {h1 == h3}")
# Safe for use in sets and dictionary keys
cluster_nodes = {h1, h2, h3}
print(f"Unique nodes in set (count: {len(cluster_nodes)}): {cluster_nodes}")Run:
uv run python hashable_host.pyOutput:
h1 == h2 : True
h1 == h3 : False
Unique nodes in set (count: 2): {HostID('prod-us', 101), HostID('prod-eu', 101)}
Case 3: Automatic Ordering with functools.total_ordering
Implementing all comparison operators (<, <=, >, >=, ==, !=) is tedious. By implementing __eq__ and just one ordering method (__lt__), @total_ordering derives the rest:
# priority_ordering.py
from functools import total_ordering
@total_ordering
class TaskPriority:
def __init__(self, level: int, name: str) -> None:
self.level = level
self.name = name
def __eq__(self, other: object) -> bool:
if not isinstance(other, TaskPriority):
return NotImplemented
return self.level == other.level
def __lt__(self, other: object) -> bool:
if not isinstance(other, TaskPriority):
return NotImplemented
return self.level < other.level
def __repr__(self) -> str:
return f"Priority({self.level}, {self.name!r})"
if __name__ == "__main__":
low = TaskPriority(1, "Background Backup")
med = TaskPriority(5, "Log Rotation")
high = TaskPriority(10, "Service Outage Failover")
print(f"low < high : {low < high}")
print(f"high >= med : {high >= med}")
tasks = [med, high, low]
tasks.sort()
print("Sorted tasks by priority:", tasks)Run:
uv run python priority_ordering.pyOutput:
low < high : True
high >= med : True
Sorted tasks by priority: [Priority(1, 'Background Backup'), Priority(5, 'Log Rotation'), Priority(10, 'Service Outage Failover')]
Case 4: Callable Objects with __call__
Defining __call__ turns an instance into a callable that retains internal state across calls:
# rate_limiter_callable.py
import time
class SimpleRateLimiter:
def __init__(self, max_calls: int, window_seconds: float) -> None:
self.max_calls = max_calls
self.window_seconds = window_seconds
self.calls: list[float] = []
def __call__(self, client_id: str) -> bool:
now = time.monotonic()
# Evict timestamps outside the sliding window
self.calls = [t for t in self.calls if now - t < self.window_seconds]
if len(self.calls) >= self.max_calls:
return False # Rate limit breached
self.calls.append(now)
return True
if __name__ == "__main__":
limiter = SimpleRateLimiter(max_calls=3, window_seconds=1.0)
# Invoked like a function: limiter("client_1")
for i in range(5):
allowed = limiter("client_1")
print(f"Request {i + 1}: {'ALLOWED' if allowed else 'DENIED'}")Run:
uv run python rate_limiter_callable.pyOutput:
Request 1: ALLOWED
Request 2: ALLOWED
Request 3: ALLOWED
Request 4: DENIED
Request 5: DENIED
Pitfalls
Pitfall 1: Defining __eq__ Without __hash__
In Python 3, if a class defines __eq__ but does not define __hash__, Python automatically sets __hash__ = None:
class Node:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return self.name == other.name
n = Node("worker")
# s = {n} -> TypeError: unhashable type: 'Node'If you override __eq__ and want the object to remain hashable, you must explicitly implement __hash__. If the object is mutable, leaving __hash__ = None is the correct safety measure to prevent hash table corruption.
Exercises
- Create a class
Vector2D(x, y)that implements__add__and__sub__for vector arithmetic, and__abs__to calculate Euclidean magnitude (\(\sqrt{x^2 + y^2}\)). - Implement a
RollingLogcontainer that stores the last \(N\) entries and implements__len__,__getitem__, and__iter__. - Create an immutable
CIDRBlockclass that implements__eq__and__hash__so that equivalent subnets can be deduplicated in aset. - Implement a stateful token-bucket rate limiter class that uses
__call__to check and consume tokens.
Further reading
- Python Documentation: The Python Data Model — Special method names.
- Python Standard Library:
functools.total_ordering. - Luciano Ramalho: Fluent Python, Second Edition (O’Reilly).