The Data Model & Dunder Methods
Back in Phase 6 you met __init__ and self, and learned the word dunder - double underscore.
You were told these names are "hooks": you define them, and Python calls them at the right moment.
Here's the secret nobody tells you up front: Python's built-in syntax is mostly an illusion.
len(x) doesn't come from privileged knowledge of how long things are - it calls x.__len__(). a + b
calls a.__add__(b). print(x) calls x.__repr__() or x.__str__(). The square brackets, the +,
the ==, the for loop - all of it is sugar over method calls on your objects. Learn which method
each piece of syntax calls, and your own classes can behave exactly like the built-ins.
This collection of hooks has a name: the data model.
The one rule: syntax dispatches to a dunder method
What it actually is. Almost every piece of Python syntax delegates to a corresponding dunder method. The syntax is the friendly face; the dunder does the work. You never call the dunder yourself - you write the syntax, and Python calls it on the object involved.
📝 The data model - Python's published contract of which dunder each operator, built-in, and piece of syntax calls. Implement the right dunders and your object plugs into the language as if it were built in.
Here's the dispatch for one example, len(x):
One idea: len() is a thin wrapper that finds your object's __len__ method and calls it. The same
shape holds for the rest of the model - find the syntax, find the dunder behind it.
A handful of the most common pairings, so the pattern is concrete:
| You write | Python calls |
|---|---|
len(x) |
x.__len__() |
x[key] |
x.__getitem__(key) |
a + b |
a.__add__(b) |
a == b |
a.__eq__(b) |
print(x) / str(x) |
x.__str__() |
repr(x) / the REPL echo |
x.__repr__() |
for item in x: |
x.__iter__() |
x() |
x.__call__() |
💡 Key point. You won't memorize this table, and you don't need to. Hold the rule - "syntax calls a dunder on the object" - and look up the specific name when you need it.
__repr__ vs __str__ - how your object describes itself
The problem it solves. Make a class and print an instance, and you get this:
=
$ python money.py
<__main__.Money object at 0x10f4c2a90>
What just happened: Python had no idea how to turn your Money into text, so it fell back to its
default: the class name and the object's memory address - useless to anyone reading a log at 2am.
You teach it with two dunders, and the difference between them matters:
📝 __repr__ - the unambiguous representation, aimed at a developer. Ideally it looks like the code
that would recreate the object: Money(500). This is what the REPL echoes and what shows up in a list, a
dict, or a debugger.
📝 __str__ - the readable representation, aimed at an end user. print() and str() use it; if
undefined, Python falls back to __repr__.
=
return f # looks like the constructor call
return f # looks like money
=
# uses __str__
# uses __repr__
# a list shows __repr__ of its contents
$ python money.py
$5.00
Money(500)
[Money(500)]
What just happened: print(m) used __str__ for the human-friendly $5.00; repr(m) used __repr__
for Money(500) - readable enough to paste back into code. Collections always show their elements'
__repr__, which is why a good one makes debugging calmer: [Money(500), Money(150)], not three memory
addresses.
⚠️ Gotcha - skipping __repr__ is a debugging tax you pay forever. Define nothing, and every log
line, error message, and print of a list of your objects shows <Money object at 0x...>. Always give
a class a __repr__ - it's the single highest-value dunder. __str__ is optional (it falls back to
__repr__); __repr__ is the one you owe yourself.
__eq__ - what "equal" means for your object (and why it drags __hash__ along)
The problem it solves. By default, two objects are equal only if they're the same object in memory
- the
is-style identity from Phase 9. You almost never mean that: twoMoney(500)values are the same amount of money, even as separate objects.
__eq__ lets you define equality by value - Python calls it for ==.
=
return f
return == # equal when the amounts match
# same amount
# different amount
$ python eq.py
True
False
What just happened: Money(500) == Money(500) is now True even though they're two distinct objects,
because your __eq__ compares cents instead of identity.
But you just broke something quietly: the moment you define __eq__, Python removes the default
__hash__, so your objects become unhashable - they can't go in a set or be used as dict keys:
=
$ python eq.py
TypeError: unhashable type: 'Money'
What just happened: Python requires that anything used as a set member or dict key have a __hash__,
and objects that are equal must have the same hash. Redefine equality but leave the old hash in
place and that promise could break - so Python disables hashing until you say what it should be.
📝 __hash__ - returns an integer Python uses to bucket the object in sets and dicts. The contract: if
a == b, then hash(a) == hash(b). Honor it by hashing the same data your __eq__ compares - usually as
a tuple.
=
return f
return ==
return # hash the SAME data __eq__ compares
=
# the two equal 500s collapse into one
$ python eq.py
{Money(500), Money(150)}
What just happened: With __hash__ mirroring __eq__, Money works in a set again - since the two
Money(500) objects are equal and hash the same, the set treats them as one. The rule: define
__eq__ and __hash__ together, over the same fields, or not at all.
⚠️ Gotcha - __eq__ without __hash__ silently costs you sets and dicts. The TypeError only fires
when you actually try to put the object in a set or use it as a key, possibly far from where you wrote
__eq__. If your "value" object should be usable as a key (most should), add __hash__ in the same
edit. (Exception: mutable objects you intend to change in place are often left deliberately unhashable,
since their hash would shift underneath the set.)
__getitem__ and __iter__ - making an object indexable and loopable
The data model is what makes x[key] and for item in x: work on the built-in types, and you can opt
your own classes in.
__getitem__ - square brackets. Define it, and x[key] calls x.__getitem__(key). key can be
anything - an integer, a string, a slice - it's your method, you decide what it means.
=
return # delegate to the underlying list
=
# calls p.__getitem__(0)
# negative indexing, for free
$ python playlist.py
Intro
Chorus
What just happened: p[0] dispatched to your __getitem__, which handed the work to the inner list.
You didn't subclass list - you implemented one hook and Playlist started behaving like a sequence.
__iter__ - the for loop. When you write for song in p:, Python calls p.__iter__() to get an
iterator, then pulls items from it one at a time. Returning iter(self.songs) borrows the list's own
iterator - the simplest correct move:
=
return # hand back the list's iterator
=
$ python playlist.py
Intro
Verse
Chorus
What just happened: for song in p: called p.__iter__(), got the list's iterator back, and walked it.
Your object is now loopable. This is the shallow end of iteration - enough to make a class work in a
for loop today. The real machinery (what an iterator actually is, __next__, and how to write your own,
including ones that generate values lazily) is the whole of
Iterators & Generators.
Operator overloading - teaching + to your type
What it actually is. Operator overloading is the data-model rule applied to math symbols: a + b
calls a.__add__(b), a - b calls a.__sub__(b), and so on. The operators carry no built-in knowledge
of your type; you supply the meaning by defining the dunder.
Combine this with the __repr__ and __eq__ from before and Money becomes a small, complete value
type:
=
return f
return f
return ==
return
# called for self + other
return
# called for self * factor
return
=
=
= + # __add__
= * 2 # __mul__
$ python money.py
$5.45
$10.00
True
What just happened: price + tax dispatched to __add__, which returned a new Money of 545 cents.
price * 2 dispatched to __mul__. Then total == Money(545) used __eq__. Money now reads exactly
like a built-in number type - +, *, ==, a clean print - but every behavior is a method you wrote.
That's the payoff of the data model: your types become first-class citizens of the language.
📝 Return a new object, don't mutate. __add__ returns a fresh Money rather than changing self -
mirroring how + works everywhere in Python (3 + 4 doesn't change 3) and keeping your type
predictable. Operators that quietly mutate their operands surprise everyone who uses them.
🪖 War story. A teammate built a
Vectorclass for a physics sim and made+mutate the left-hand vector in place to "save an allocation." A week later, positions were drifting -total = a + bwas silently corruptingaevery frame. The fix was one line: return a newVector. Operators are expected to be pure; honor the reader's intuition over the micro-optimization.
Recap
- The data model is one rule: syntax dispatches to a dunder on the object.
len(x)→x.__len__(),a + b→a.__add__(b),x[k]→x.__getitem__(k). Learn the rule, look up the name. __repr__vs__str__:__repr__is the unambiguous, developer-facing form (also what lists and the REPL show);__str__is the readable formprintuses. Always define__repr__- skipping it gives you the useless<object at 0x...>in every log.__eq__defines value equality (==), but makes your object unhashable until you add__hash__- define the two together over the same fields, or sets and dicts break.__getitem__makesx[key]work;__iter__makesfor item in x:work. (The deep version of iteration is next.)- Operator overloading is the same rule for symbols: define
__add__,__mul__, etc. Return new objects; don't mutate operands.
You can now make your own classes behave like Python's built-ins. __iter__ was touched only lightly -
iteration is deep enough, and useful enough, to deserve its own phase. Next: what an iterator really is,
and how generators produce values lazily, one at a time, without building the whole sequence in memory.
Quick check - make sure these stuck:
[
{"q":"When you write len(x), what does Python actually do?","choices":["It reads a hidden length field that every object stores","It calls x.__len__() - len() is a thin wrapper over that dunder","It counts the object's attributes in memory"],"answer":1,"explain":"The data model is one rule: syntax dispatches to a dunder. len(x) finds and calls x.__len__(); it has no privileged knowledge of length."},
{"q":"Why is defining __repr__ on your class considered the single highest-value dunder?","choices":["Without it, print(x), logs, and lists of your objects all show a useless <object at 0x...>","It is required before you can use == on instances","It makes the object hashable"],"answer":0,"explain":"__repr__ is the developer-facing form shown by the REPL, debuggers, and inside collections. Skip it and every log line shows a memory address instead of something readable."},
{"q":"You add __eq__ to a class so two equal-valued instances compare equal. What breaks?","choices":["Nothing - __eq__ is fully self-contained","The instances become unhashable: defining __eq__ removes the default __hash__, so they can't go in a set or be dict keys until you add __hash__","print() stops working on them"],"answer":1,"explain":"Equal objects must hash equally, so once you redefine equality Python disables the inherited hash. Define __eq__ and __hash__ together over the same fields, or sets and dicts break."}
]
← Phase 9: Idioms & Common Gotchas · Guide overview · Phase 11: Iterators & Generators →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. When you write len(x), what does Python actually do?
2. Why is defining __repr__ on your class considered the single highest-value dunder?
3. You add __eq__ to a class so two equal-valued instances compare equal. What breaks?