The Python Data Model and Dunder Protocols

Updated

September 7, 2026

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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

  1. 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}\)).
  2. Implement a RollingLog container that stores the last \(N\) entries and implements __len__, __getitem__, and __iter__.
  3. Create an immutable CIDRBlock class that implements __eq__ and __hash__ so that equivalent subnets can be deduplicated in a set.
  4. 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).