The Python data model

Intermediate60 min

Make your own classes work with print, ==, sorting, len, for loops and operators by implementing the special methods Python calls behind the scenes.

What you will learn
  • Explain why Python calls special (dunder) methods and that you rarely call them yourself
  • Write __repr__ and __str__ for readable output
  • Implement __eq__ and __hash__ together, and say why defining one affects the other
  • Make instances sortable with __lt__, and countable and iterable with __len__ and __iter__
  • Support obj[i], in, if obj and obj() with __getitem__, __contains__, __bool__ and __call__
  • Overload an operator such as + and return NotImplemented for unsupported operands
On this page

Why does len(songs) work on a list, a + b on two strings, and for x in data on almost anything? Because these built-in operations ask the object to do the work, by calling a method with a special name. Those names start and end with two underscores, which is why they are called dunder methods. The set of them, and the rules for using them, is the data model. Implement the right ones and your class feels like a built-in type.

You write the dunder methods but almost never call them directly. You write len(mix), and Python calls mix.__len__() for you.

Showing an object: __repr__ and __str__

When you print an instance of a plain class, you get something unhelpful, like <__main__.Point object at 0x7f...>. Define __repr__ to say what the object is. By convention it looks like the code that would build it:

c20_repr.py
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
p = Point(1, 2)
print(p)
print([p, Point(3, 4)])
print(repr(p))
Output
Point(1, 2)
[Point(1, 2), Point(3, 4)]
Point(1, 2)

print uses __repr__ when the class defines nothing else, and so do lists when they display their contents. __repr__ is aimed at developers, and it shows up in tracebacks and debugger sessions.

When a friendlier text for end users is wanted, add __str__. print() and f-strings use __str__ first, and containers still use __repr__:

c20_str.py
class Money:
def __init__(self, cents):
self.cents = cents
def __repr__(self):
return f"Money({self.cents})"
def __str__(self):
return f"${self.cents // 100}.{self.cents % 100:02d}"
price = Money(1999)
print(price)
print(repr(price))
print([price])
print(f"total: {price}")
Output
$19.99
Money(1999)
[Money(1999)]
total: $19.99

If you write only one, write __repr__. str() falls back to it when __str__ is missing.

Equality: __eq__

By default == on instances means “are these the very same object?”:

c20_eq_default.py
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
a = Point(1, 2)
b = Point(1, 2)
print(a == a)
print(a == b)
Output
True
False

Two points with the same coordinates are different objects, so they are unequal. Define __eq__ to compare by value instead:

c20_eq.py
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
a = Point(1, 2)
b = Point(1, 2)
print(a == b)
print(a != Point(5, 5))
print(a == (1, 2))
Output
True
True
False

The isinstance check handles the case where the other side is a different type. Returning NotImplemented, a special built-in value and not an exception, tells Python “I don’t know how to compare with that.” Python then tries the other operand’s __eq__, and if neither knows, falls back to identity, so a == (1, 2) is False. Python also derives != from __eq__, so you do not write it yourself.

Hashing: __hash__

Sets and dictionary keys need a hash, a whole number that is the same for equal objects. Here is a rule that surprises most learners: a class that defines __eq__ and not __hash__ becomes unhashable.

c20_hash_none.py
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
print(Point.__hash__ is None)
try:
{Point(1, 2)}
except TypeError as error:
print("TypeError:", error)
Output
True
TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')

Python removed the hash because the old one, based on identity, would now disagree with your new idea of equality. The longer wording of this message, which names the set, is new in Python 3.14 Requires version 3.14 or later. Earlier versions say only unhashable type: 'Point', and the behaviour is the same.

The rule to follow is that objects which compare equal must have equal hashes. The usual recipe hashes a tuple of the fields that __eq__ compares:

c20_hash.py
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
points = {Point(1, 2), Point(1, 2), Point(3, 4)}
print(len(points))
labels = {Point(0, 0): "origin"}
print(labels[Point(0, 0)])
Output
2
origin

The duplicate point vanished from the set, and a freshly built Point(0, 0) found the dictionary entry. This relies on the fields not changing after the object goes into a set or dictionary, because a changed field changes the hash and the entry becomes unreachable. Only define __hash__ on objects you treat as immutable. The dataclasses lesson shows an easy way to get that.

Ordering: __lt__

sorted, min and max only ever ask “is this one less than that one?”, which is __lt__:

c20_lt.py
class Version:
def __init__(self, major, minor):
self.major = major
self.minor = minor
def __repr__(self):
return f"Version({self.major}, {self.minor})"
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor) < (other.major, other.minor)
versions = [Version(3, 12), Version(2, 7), Version(3, 9)]
print(sorted(versions))
print(min(versions), max(versions))
print(Version(3, 9) > Version(3, 8))
Output
[Version(2, 7), Version(3, 9), Version(3, 12)]
Version(2, 7) Version(3, 12)
True

Comparing tuples does the field-by-field work. Notice that > worked without a __gt__ method: when a > b has no method, Python tries b < a instead. That reflection is why __lt__ alone is enough for sorting. If you need all six comparison operators, the standard library offers functools.total_ordering, and dataclasses can generate them with order=True.

Size and iteration: __len__ and __iter__

Define __len__ and your class works with len(). Define __iter__ and it works in for loops, in list(), and in other places that need to go through its items one by one:

c20_len_iter.py
class Playlist:
def __init__(self, songs):
self._songs = list(songs)
def __len__(self):
return len(self._songs)
def __iter__(self):
return iter(self._songs)
mix = Playlist(["Intro", "Waltz", "Finale"])
print(len(mix))
for song in mix:
print(song)
print("Waltz" in mix)
print(list(mix))
print(bool(mix), bool(Playlist([])))
Output
3
Intro
Waltz
Finale
True
['Intro', 'Waltz', 'Finale']
True False

__iter__ here hands back an iterator over the inner list, using the built-in iter. The next module explains iterators in full, and for now this one-line form is all you need. Three things came free. The in check worked, because Python falls back to looping through __iter__. list(mix) worked. And bool(Playlist([])) is False, because an object with __len__ is falsy when its length is zero, the same rule you learned for empty lists.

Indexing, membership, truth and calls

Four more dunder methods cover everyday syntax: x[i], in, if x: and x(). A small Shelf class and a Multiplier class show them one at a time.

Indexing: __getitem__

obj[key] calls obj.__getitem__(key). The key can be an integer, a negative integer or a slice, and passing it on to the inner list handles all three:

c20_getitem.py
class Shelf:
def __init__(self, books):
self._books = list(books)
def __getitem__(self, index):
return self._books[index]
shelf = Shelf(["Dune", "Emma", "Ulysses"])
print(shelf[0])
print(shelf[-1])
print(shelf[0:2])
Output
Dune
Ulysses
['Dune', 'Emma']

shelf[0:2] reached __getitem__ as a slice object, and the list knew what to do with it. Python also has an older fallback: with __getitem__ and no __iter__, a for loop and the in operator try shelf[0], shelf[1] and so on until an IndexError ends the loop:

c20_getitem_for.py
class Shelf:
def __init__(self, books):
self._books = list(books)
def __getitem__(self, index):
return self._books[index]
shelf = Shelf(["Dune", "Emma", "Ulysses"])
for book in shelf:
print(book)
print("Emma" in shelf)
Output
Dune
Emma
Ulysses
True

For a class that holds a sequence, __getitem__ alone is enough to be read like one. Add __len__ as well so that len(shelf) works.

Membership: __contains__

item in obj calls obj.__contains__(item) when it exists. Define it when the check should follow your own rule, here ignoring capitals:

c20_contains.py
class Shelf:
def __init__(self, books):
self._books = list(books)
def __contains__(self, title):
return title.lower() in [book.lower() for book in self._books]
shelf = Shelf(["Dune", "Emma"])
print("dune" in shelf)
print("Hamlet" in shelf)
print("Hamlet" not in shelf)
Output
True
False
True

not in needs nothing extra: Python negates the answer of __contains__.

Truth: __bool__

if obj: and bool(obj) call obj.__bool__(), which must return True or False. Without it, Python uses __len__ (zero is falsy), and without that every instance is truthy:

c20_bool.py
class Wallet:
def __init__(self, cents):
self.cents = cents
def __bool__(self):
return self.cents > 0
for wallet in (Wallet(250), Wallet(0)):
if wallet:
print("has money")
else:
print("empty")
print(bool(Wallet(1)))
Output
has money
empty
True

Calling: __call__

obj(args) calls obj.__call__(args). An instance with __call__ behaves like a function that also remembers state:

c20_call.py
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
triple = Multiplier(3)
print(triple(14))
print(callable(triple))
print(list(map(triple, [1, 2, 3])))
Output
42
True
[3, 6, 9]

callable(triple) is True, so triple can be passed anywhere a function is expected, such as map or the key= argument of sorted. The decorators lesson uses this to build decorators from classes.

Operators: __add__ and friends

Every operator maps to a dunder method: + to __add__, * to __mul__, - to __sub__, abs() to __abs__, and so on. Implement one and the operator works:

c20_ops.py
class Money:
def __init__(self, cents):
self.cents = cents
def __repr__(self):
return f"Money({self.cents})"
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
return Money(self.cents + other.cents)
def __mul__(self, factor):
if not isinstance(factor, int):
return NotImplemented
return Money(self.cents * factor)
print(Money(250) + Money(199))
print(Money(250) * 3)
Output
Money(449)
Money(750)

Both methods check the other operand and return NotImplemented for anything unsupported. Python then raises a TypeError with a clear message, which is better than returning nonsense:

c20_ops_error.py
class Money:
def __init__(self, cents):
self.cents = cents
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
return Money(self.cents + other.cents)
try:
Money(250) + 5
except TypeError as error:
print("TypeError:", error)
Output
TypeError: unsupported operand type(s) for +: 'Money' and 'int'

Keep operators for cases where the meaning is obvious. Adding two Money values is natural. Giving + a creative meaning on a class of your own will confuse the next reader.

Common pitfalls

Defining __eq__ without __hash__. The instances can no longer go into sets or be dictionary keys, as shown above. Define both, or neither.

Hashing a mutable object. If a field that feeds the hash can change, a set containing the object can lose track of it. Make such objects immutable, or leave them unhashable.

A __repr__ that returns something other than a string. It must return str. Returning a number or None raises TypeError.

Raising instead of returning NotImplemented. In __eq__, __lt__ and the arithmetic methods, returning NotImplemented lets the other operand try. Raising TypeError yourself skips that chance.

Calling dunder methods directly. Write len(x), repr(x) and a + b. The direct form x.__len__() bypasses checks that the built-in functions make, and it reads strangely.

ExerciseA Vector class

Write a class Vector(x, y) with these special methods: __repr__ giving Vector(3, 4); __eq__ comparing coordinates; __hash__ consistent with it; __add__ returning a new vector; and __abs__ returning the length, which math.hypot(x, y) computes.

Test script and expected output:

vector_check.py
a = Vector(3, 4)
b = Vector(1, 2)
print(a + b)
print(abs(a))
print(a == Vector(3, 4))
print(a + b == Vector(4, 6))
print({a, Vector(3, 4)})
Output
Vector(4, 6)
5.0
True
True
{Vector(3, 4)}
Show solution
c20_ex_vector.py
import math
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x + other.x, self.y + other.y)
def __abs__(self):
return math.hypot(self.x, self.y)
a = Vector(3, 4)
b = Vector(1, 2)
print(a + b)
print(abs(a))
print(a == Vector(3, 4))
print(a + b == Vector(4, 6))
print({a, Vector(3, 4)})
Output
Vector(4, 6)
5.0
True
True
{Vector(3, 4)}

Because __eq__ and __hash__ agree, the set holds one vector, not two. Because __add__ builds a new Vector and leaves its operands alone, a still has its original coordinates for the later lines.

ExerciseA team you can loop over

Write a class Team(name, members) that stores the members in a private list. Make len(team) return the number of members, make for member in team loop over them, and give it a __repr__ that shows Team('Docs', ['Asha', 'Ben']). You do not need to write anything for the in operator.

Create Team("Docs", ["Asha", "Ben"]), then print len(team), the two results of "Ben" in team and "Chi" in team on one line, the upper-case form of every member, and the team itself. Expected output:

2
True False
ASHA
BEN
Team('Docs', ['Asha', 'Ben'])
Show solution
c20_ex_team.py
class Team:
def __init__(self, name, members):
self.name = name
self._members = list(members)
def __len__(self):
return len(self._members)
def __iter__(self):
return iter(self._members)
def __repr__(self):
return f"Team({self.name!r}, {self._members!r})"
team = Team("Docs", ["Asha", "Ben"])
print(len(team))
print("Ben" in team, "Chi" in team)
for member in team:
print(member.upper())
print(team)
Output
2
True False
ASHA
BEN
Team('Docs', ['Asha', 'Ben'])

The !r in the f-string applies repr to the name and the list, which is why the output has quotes. "Ben" in team works through __iter__, as the lesson explained.

ExercisePredict equal and identical

Here Tag defines __eq__ and __hash__ from its label. Before running the script, write what each of the three lines prints, and explain how the last one differs from the first two.

c20_ex_predict.py
class Tag:
def __init__(self, label):
self.label = label
def __eq__(self, other):
return self.label == other.label
def __hash__(self):
return hash(self.label)
a = Tag("py")
b = Tag("py")
print(a is b)
print(a == b)
print(len({a, b}))
Show solution
False
True
1

a is b asks about identity, and these are two separate objects, so it is False. a == b calls __eq__, which compares labels, so it is True. The set discards the second tag because the two have equal hashes and compare equal, so len is 1.

Summary

  • Python implements len(), print, ==, <, +, for and more by calling dunder methods on your object. You write them and let Python call them.
  • __repr__ is the developer view and the fallback for __str__. __str__ is the friendly text used by print and f-strings.
  • __eq__ compares by value. If you define it, define __hash__ as well, over the same fields, and only for objects that do not change.
  • __lt__ is enough for sorted, min, max and for >.
  • __len__ and __iter__ make a class sized, loop-able and usable with in. The iterators lesson explains what __iter__ must return.
  • __getitem__ gives obj[i] and slices, __contains__ customises in, __bool__ sets truthiness, and __call__ makes an instance callable.
  • Return NotImplemented, not an exception, for operands you do not support.
  • Going further: the advanced lesson Descriptors shows how properties work underneath.