🐍 Python Course · Stage 1 · Lesson 20 of 89 · Data Model
Course Overview →

🟩 Start with the Beginner tab. Read it fully before moving on.

← Abstract Base Classes Error Handling →
thecodex.expert · The Codex Family of Knowledge
Python

The Python Data Model (Dunder Methods)

The data model is the set of special "dunder" methods (__init__, __len__, __getitem__, and dozens more) that let your own classes integrate seamlessly with Python’s built-in syntax and functions.

Python 3.13 docs.python.org Last verified:
Canonical Definition

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.

🟩 Beginner

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.

⏱ 25 min📄 1 section🔶 Prerequisite: Object-Oriented Python
The idea

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.

Pythondunder_basics.py
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.
__repr__ vs __str__

__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

Continue to Error Handling →

🔵 Intermediate

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.

⏱ 30 min📄 2 sections🔶 Prerequisite: Beginner tab
💡

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

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

Pythonoperators.py
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
Commonly confused
Implementing __eq__ disables __hash__. If you define __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__ provides iteration for free. If a class implements __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

Continue to Error Handling →

🔴 Expert

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.

⏱ 20 min📄 1 section🔶 Prerequisite: After Stage 2

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

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

Continue to Error Handling →

Sources

1
Python Language Reference §3 — Data Model (special method names). docs.python.org/3/reference/datamodel.html.
2
Python Language Reference §3.3.1 — Basic customization (__new__, __init__, __repr__, __eq__, __hash__). docs.python.org/3/reference/datamodel.html#basic-customization.
3
Python Language Reference §3.3.7 — Emulating container types. docs.python.org/3/reference/datamodel.html#emulating-container-types.
4
Python Language Reference §3.3.8 — Emulating numeric types (reflected operands). docs.python.org/3/reference/datamodel.html#emulating-numeric-types.
Source confidence: High Last verified: Primary source: Python Language Reference §3 (Data Model)