Python Data Model and Magic Methods

Understand the Python data model: how protocols and magic methods let your objects work with built-in syntax like len(), +, in, and for loops.

8 min read

The Python data model is the set of protocols that connect language syntax to method calls on objects. When you write len(items), a + b, or for x in container, Python is calling a specific special method behind the scenes on your object.

This consistent mapping between syntax and methods is the heart of Python's design. Your custom collection can work with the len function, your custom number type can work with the + and * operators, and your custom object can decide what str(obj) and repr(obj) return.

The article on special methods in Python covers individual methods in detail. This article explains the data model itself: what protocols are, how the mapping works, and why the system is designed this way.

How a built-in function becomes a method call

Every built-in operation follows the same pattern. Python calls a special method on the object. That method returns a value the built-in function or syntax then uses.

You can see this by calling both the built-in function and the special method it delegates to. The results are identical.

pythonpython
items = [10, 20, 30]
 
length = len(items)
length_via_method = items.__len__()
 
print(length)
print(length_via_method)

The built-in len function delegates to __len__ on the list object. Both call paths produce the same result, confirming the direct mapping between function and method.

texttext
3
3

Always use the built-in len rather than calling the dunder method directly. The built-in adds type checking and handles edge cases, and the special method is only the implementation hook, not the public API.

The same pattern applies to str(obj) and repr(obj). Implementing __str__ and its repr counterpart on your own classes lets you control how your objects display.

pythonpython
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
    def __str__(self):
        return f"Point({self.x}, {self.y})"
 
    def __repr__(self):
        return f"Point({self.x!r}, {self.y!r})"
 
p = Point(3, 4)
print(str(p))
print(repr(p))

The str function calls that method for a human-readable coordinate format. The repr function calls its own method to produce a debugging-focused representation with the class name and constructor arguments.

texttext
Point(3, 4)
Point(3, 4)

Protocols: the duck-typing foundation

A protocol is an informal interface defined by a set of magic methods. Any object that implements those methods qualifies for the associated operations. You do not need to inherit from a specific base class or register with a framework.

The iteration protocol is the clearest example. An object is iterable if it implements __iter__ and returns an iterator, and it is an iterator if it also implements a next method that returns items or raises StopIteration.

pythonpython
class Countdown:
    def __init__(self, start):
        self.current = start
 
    def __iter__(self):
        return self
 
    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value
 
for num in Countdown(3):
    print(num)

The Countdown class does not inherit from any special base class. It simply implements the two required methods, and Python's for loop accepts it.

texttext
3
2
1

The for loop calls that method to get an iterator, then repeatedly calls its next method until StopIteration signals the end. The article on Python iterables and iterators covers this protocol in depth.

The container protocol

Any object that implements __len__ and __getitem__ works with the len function, indexing syntax, slicing, and membership testing. You do not need to inherit from list or any collection class.

pythonpython
class Weekdays:
    _days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
 
    def __len__(self):
        return len(self._days)
 
    def __getitem__(self, index):
        return self._days[index]
 
days = Weekdays()
print(len(days))
print(days[0])
print(days[1:4])
print("Fri" in days)

Python finds the length and indexing methods shown above whenever you call len(), index, or slice the object. The in operator works because Python falls back to iterating and comparing when a dedicated membership method is missing.

texttext
7
Mon
['Tue', 'Wed', 'Thu']
True

Adding a dedicated membership method gives you more efficient and flexible control than the iterate-and-compare fallback, especially for large or computed collections.

The numeric protocol

Arithmetic operators each map to a magic method: the + operator calls __add__, and the * operator calls a similar multiplication method. Implementing these methods lets your objects work with operator syntax.

pythonpython
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
 
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
 
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)

The + operator calls v1.add(v2), which creates and returns a new Vector rather than modifying either of the original two vectors.

texttext
Vector(4, 6)

If the left operand does not know how to handle the right operand's type, Python tries the reflected operation on the right operand as a fallback. This two-way negotiation lets operators work with types you may not know about at design time.

The comparison protocol

Comparison operators each map to their own magic method, and implementing all of them lets your objects sort, rank, and compare like built-in types:

OperatorMethod
==__eq__
<__lt__
<=__le__
>__gt__
>=__ge__
pythonpython
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
 
    def __eq__(self, other):
        if not isinstance(other, Person):
            return NotImplemented
        return self.name == other.name and self.age == other.age
 
    def __lt__(self, other):
        if not isinstance(other, Person):
            return NotImplemented
        return self.age < other.age
 
alice = Person("Alice", 30)
bob = Person("Bob", 25)
print(alice == bob)
print(alice < bob)

Alice and Bob are different people, so equality returns False. Alice is not younger than Bob, so less-than also returns False.

texttext
False
False

Returning NotImplemented from a comparison method tells Python to try the reflected operation on the other operand. This is important for interoperability between different types.

The callable protocol

Any object that implements __call__ can be called like a function. This lets you create objects that maintain state between calls while being invoked with familiar function syntax.

pythonpython
class Multiplier:
    def __init__(self, factor):
        self.factor = factor
 
    def __call__(self, value):
        return value * self.factor
 
double = Multiplier(2)
triple = Multiplier(3)
 
print(double(10))
print(triple(10))
print(callable(double))

A Multiplier instance remembers its factor and applies it when called. The built-in callable function reports that instances of this class can be invoked, which is useful when your code needs to check that behaviour before calling.

texttext
20
30
True

The context manager protocol

Objects that implement __enter__ and __exit__ work with the with statement. This is how file handles, locks, and database connections provide automatic cleanup after a block completes.

pythonpython
class ManagedResource:
    def __enter__(self):
        print("Acquiring resource")
        return self
 
    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Releasing resource")
        return False
 
with ManagedResource() as r:
    print("Using resource")

The enter method runs when the block starts, and the exit method runs when the block ends, whether normally or with an exception.

texttext
Acquiring resource
Using resource
Releasing resource

The return value of the enter method is bound to the variable after as. Returning False from the exit method lets exceptions propagate normally, while returning True suppresses them from reaching the caller. The article on Python context managers covers this in more detail.

How Python decides which method to call

When Python encounters len(obj), it does not directly call obj.len(). The built-in function first checks that the object's type defines that method through its type slot mechanism, and if it does not, Python raises a TypeError.

For binary operators like the + operator, Python looks up the add method on the type of the left operand rather than the instance directly, because looking it up on the instance would bypass the class and its metaclass. This design keeps the data model consistent and fast.

If a.add(b) returns NotImplemented, Python tries b.radd(a) as a fallback. If neither side handles the operation, Python raises TypeError, and this two-way negotiation lets operators work across types.

Why the data model matters for everyday code

You interact with the data model constantly, even if you never write a dunder method yourself. When you use the len function, the str function, or the in operator, Python calls magic methods behind the scenes.

The for loop, the with statement, the + operator, and the == operator all work the same way. Understanding this mapping helps you debug unexpected behaviour.

When you write a class, the data model gives you a clear checklist: a collection-like class should implement length and indexing, a comparable class should implement equality, and a resource wrapper should implement the enter and exit methods.

The earlier article on everything being an object in Python showed that functions, classes, and modules are all objects. The data model is what gives each of those objects its behaviour, since a function works with call syntax and a module exposes attributes only because their types define the matching special methods.

Rune AI

Rune AI

Key Insights

  • The Python data model maps language syntax and built-in functions to special method calls on objects.
  • Magic methods (dunder methods) have double-underscore names and are called automatically by the interpreter.
  • Implementing __len__ and __getitem__ is enough to make an object work with len(), indexing, and for loops.
  • The data model uses protocols: any object that implements the right methods qualifies for the corresponding operation.
  • You should never call magic methods directly; rely on the built-in function or syntax that triggers them.
RunePowered by Rune AI

Frequently Asked Questions

What is the Python data model?

The Python data model is the set of protocols that define how objects interact with language syntax and built-in functions. When you write len(obj), Python calls `obj.__len__()`. When you write a + b, Python calls `a.__add__(b)`. The data model is the contract that lets your custom objects work with Python's syntax as naturally as built-in types do.

Why are they called magic methods?

Magic methods, also called dunder methods (double underscore methods), are special methods with names like `__len__`, `__str__`, and `__add__`. Python calls them automatically in response to specific syntax or built-in function calls. You rarely call them directly. The double-underscore naming convention reserves these names for the language's use, which is why you should not invent your own dunder names for regular methods.

Conclusion

The Python data model is what makes Python feel consistent. Every built-in operation, from len() to + to for loops, is a method call in disguise. When you implement the right magic methods on your own classes, your objects gain the same syntactic support that built-in types have. The next article covers the most important special methods one by one with practical examples.