Phase 3 · Object-Oriented PythonModule 16~38 min read

Magic (Dunder) Methods & Operator Overloading

Make your objects behave like built-ins with Python's special methods.

What you'll learn

Ever wondered how len(x), a + b, and for item in x work on built-in types — and how you can make your objects behave the same way? The answer is dunder methods ("double underscore"). They're the hooks Python calls behind every operator and built-in function.

By the end you'll be able to:

  • Explain what special (dunder) methods are
  • Give objects a useful __str__ and __repr__
  • Overload operators like + and *
  • Support comparison, len(), indexing, and in

What dunder methods are

Dunder methods (also called magic or special methods) are named with leading and trailing double underscores, like __init__. You never call them directly — Python calls them for you when you use the matching syntax. Write a + b and Python runs a.__add__(b); call len(x) and it runs x.__len__(). Implementing them lets your classes plug into Python's syntax as first-class citizens.

__str__ & __repr__

The two most useful dunders control how your object appears as text. __repr__ is the unambiguous, developer-facing form (ideally something you could paste back into code); __str__ is the friendly, user-facing form used by print(). If you only write one, make it __repr__ — Python falls back to it:

money.py
class Money:
    def __init__(self, dollars):
        self.dollars = dollars

    def __repr__(self):                 # for developers / debugging
        return f"Money({self.dollars})"

    def __str__(self):                  # for end users / print()
        return f"${self.dollars:.2f}"

m = Money(19.5)
print(m)            # uses __str__
print(repr(m))      # uses __repr__
print([m, m])       # a list uses __repr__ for its items

Tip

Notice a container like a list uses __repr__ for its elements — which is why a good __repr__ makes debugging so much nicer. Always give your classes at least a __repr__; the default <Money object at 0x...> tells you nothing.

Operator overloading

You can define what the arithmetic operators mean for your type. __add__ powers +, __sub__ powers -, __mul__ powers *, and so on. This is how libraries like NumPy let you write a + b on whole arrays:

vector.py
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):           # defines the + operator
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, k):               # v * scalar
        return Vector(self.x * k, self.y * k)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

print(Vector(1, 2) + Vector(3, 4))     # Vector(4, 6)
print(Vector(1, 2) * 3)                # Vector(3, 6)

Watch out

Overload operators only when the meaning is obvious. + for adding vectors is intuitive; + that secretly sends an email is a trap. If a reader can't guess what the operator does, use a named method instead.

Comparison methods

Define __eq__ for == and __lt__ for <, and your objects become comparable and sortable. The @total_ordering decorator is a lovely shortcut: give it __eq__ and __lt__, and it generates the rest (<=, >, >=) for you:

temperature.py
from functools import total_ordering

@total_ordering                          # fills in <=, >, >= from __eq__ and __lt__
class Temperature:
    def __init__(self, degrees):
        self.degrees = degrees

    def __eq__(self, other):
        return self.degrees == other.degrees

    def __lt__(self, other):
        return self.degrees < other.degrees

temps = [Temperature(20), Temperature(5), Temperature(15)]
print(sorted(t.degrees for t in temps))   # sorting now works
print(Temperature(5) < Temperature(15))

Container protocols

Make your object act like a collection by implementing the container dunders: __len__ for len(), __getitem__ for indexing (which also makes it iterable!), and __contains__ for the in operator:

playlist.py
class Playlist:
    def __init__(self, songs):
        self.songs = songs

    def __len__(self):                  # len(playlist)
        return len(self.songs)

    def __getitem__(self, i):           # playlist[i]  AND iteration
        return self.songs[i]

    def __contains__(self, song):       # song in playlist
        return song in self.songs

p = Playlist(["Intro", "Verse", "Chorus"])
print(len(p))                # 3
print(p[1])                  # Verse
print("Chorus" in p)         # True
for song in p:               # __getitem__ makes it iterable
    print(song)

Key idea

This is the essence of Python's data model: built-in functions and operators are just calls to dunder methods. Implement the right ones and your custom types work everywhere a built-in would — with sorted(), for loops, in, and more.

Recap & quick check

Key takeaways

  • Dunder (special) methods like __add__ and __len__ hook into Python's operators and built-ins.
  • __repr__ is the unambiguous developer form; __str__ is the friendly form for print(). Always define __repr__.
  • Overload operators (__add__, __mul__, …) — but only when the meaning is obvious.
  • __eq__ + __lt__ (with @total_ordering) make objects comparable and sortable.
  • __len__, __getitem__, and __contains__ make an object behave like a container.

Quick check

1. What runs when you write a + b for custom objects?

2. Which method should you always define for good debugging output?

3. What does @total_ordering do?

4. Implementing __getitem__ also gives you…

5. When should you overload an operator?

You can now make objects that feel built-in. Next up: Module 17 — Dataclasses, Enums & Structured Data.