Module 5 · Object-Oriented Programming ⏱ 15 min

Magic Methods & Operator Overloading

By the end of this lesson you will be able to:
  • Customize how an object prints with __str__ and __repr__
  • Make objects work with the + operator by defining __add__
  • Control whether two objects count as equal with __eq__
  • Unlock sorted() and len() with __lt__ and __len__

Dunder methods — short for double underscore, like __init__ and __str__ — are the hooks that let your objects take part in Python's built-in operations. You have already written __init__; now you will meet the ones that decide how an object prints, adds, and compares.

Define __str__, and Python uses it whenever someone calls print() on the object or converts it with str():

class Money:
    def __init__(self, dollars):
        self.dollars = dollars
    def __str__(self):
        return f'${self.dollars}'

print(Money(50))   # $50

You never call these methods yourself. Python calls them in response to print, +, ==, len(), and friends — which is why they are sometimes described as magic. They are not magic at all, just agreed-upon names Python looks for.

flowchart LR
  O["a + b"] --> M["a.__add__(b)"] --> R["new object"]
  style O fill:#3776ab,color:#fff
An operator like + is sugar for a dunder method call on the left object.

Two ways to display an object

__str__ is for people — a readable label used by print. __repr__ is for programmers — an unambiguous representation used by repr() and shown when a list contains your objects. If you write only one, write __repr__; it also serves as the fallback for str() when __str__ is missing.

Operators are dunder methods too. a + b calls a.__add__(b), and a == b calls a.__eq__(b). Define them and your objects gain + and ==:

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 __eq__(self, other):
        return self.x == other.x and self.y == other.y

print(Vector(1, 2) + Vector(3, 4) == Vector(4, 6))   # True
Vector supports + via __add__ and prints via __repr__. Press Run.
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)
v3 = v1 + v2
print(v3)
print(v3.x, v3.y)

What you see when you print

If a class defines neither __str__ nor __repr__, printing an instance gives an ugly default like <__main__.Money object at 0x7f...> — useless to a human reader. Defining __str__ fixes what print shows, while __repr__ fixes what you see in the REPL and inside containers such as lists.

The two methods sit in a small fallback chain. print and str() call __str__; when it is missing, Python falls back to __repr__. That is why writing only __repr__ is safe — every display path eventually reaches it. A good __repr__ looks like the code you would type to rebuild the object, so Vector(4, 6) beats a vague phrase like Vector object, x=4, y=6.

flowchart LR
  P["print(obj)"] --> S["str(obj)"]
  S --> ST{"__str__ defined?"}
  ST -- "yes" --> O1["calls __str__"]
  ST -- "no - falls back" --> O2["calls __repr__"]
  style S fill:#3776ab,color:#fff
  style O2 fill:#b45309,color:#fff
str() tries __str__ first, then falls back to __repr__.

More hooks: length, truth, and comparison

Dunders reach far beyond printing. Define __len__ and len(obj) works; define __bool__ and the object can be tested directly in an if. Comparisons are dunders too: __eq__ powers ==, __lt__ powers <, and once < exists, sorted() and min() just work on your objects with no extra argument.

This is the real payoff of operator overloading. You are not inventing new syntax — you are plugging your class into the tools Python already provides for built-in types. A Vector that supports + and == can be reasoned about at a glance, because it behaves like the numbers and strings the reader already knows:

print(len(container))      # calls container.__len__()
print(v1 == v2)            # calls v1.__eq__(v2)
print(sorted(items))       # calls item.__lt__(other)
Defining __lt__ lets sorted() order your objects with no key argument.
class Team:
    def __init__(self, name, wins):
        self.name = name
        self.wins = wins
    def __lt__(self, other):
        return self.wins < other.wins
    def __repr__(self):
        return f'Team({self.name}, {self.wins})'

teams = [Team('b', 5), Team('a', 9), Team('c', 7)]
for t in sorted(teams):
    print(t)
Exercise

Give Temperature a __str__ method so that str(Temperature(21)) returns "21C".

class Temperature:
    def __init__(self, degrees):
        self.degrees = degrees

    # define __str__(self)
Exercise

Give Vector an __add__(self, other) that returns a new Vector with added coordinates, and a __repr__(self) that returns 'Vector(x, y)'.

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

    # define __add__(self, other) and __repr__(self)
Exercise

What does this print? __str__ decides what print shows.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __str__(self):
        return f'({self.x}, {self.y})'

p = Point(2, 3)
print(p)
Exercise

This __repr__ prints instead of returning, so repr(m) fails. Fix it so repr(Money(5)) returns the string 'Money(5)'.

class Money:
    def __init__(self, amount):
        self.amount = amount
    def __repr__(self):
        print(f'Money({self.amount})')   # bug: prints instead of returning
Exercise

Give Score a __lt__(self, other) that compares by points, and a __repr__(self) that returns 'Score(name, points)'. Once __lt__ exists, sorted() orders Score objects with no key= argument.

class Score:
    def __init__(self, name, points):
        self.name = name
        self.points = points

    # define __lt__(self, other) and __repr__(self)

Rich comparisons: Python does not guess

Defining __eq__ lets your objects use ==, and defining __lt__ lets them use <. What Python does not do is derive one from the other — giving your class < does not automatically grant <=, >, or ==. Each operator maps to its own dunder, and you must define the ones you actually want.

That sounds tedious, and it can be. The functools module ships a decorator called total_ordering that fills in the missing comparisons once you define __eq__ and one ordering method such as __lt__. Reach for it when your class has a natural ordering and you want the full set of comparison operators without writing each one by hand.

Why repr earns its keep

A good __repr__ pays off long after you write it. It is what you see when you inspect a list of your objects in the REPL, what prints in a traceback when something crashes, and what debugging tools display as you step through code. The default <Money object at 0x...> tells you almost nothing; a deliberate Money(50) tells you the type and the state at a glance.

The rule of thumb is to make __repr__ look like valid Python that could rebuild the object. If eval(repr(obj)) would reconstruct something equal to obj, you have written a strong one. That goal is not always reachable, but aiming for it keeps your objects legible under pressure.

Recap

  • Dunder methods hook your objects into Python's built-in operations.
  • __str__ is the human label for print; __repr__ is the programmer-facing form and the fallback for str().
  • a + b is a.__add__(b); a == b is a.__eq__(b) — define the dunder, gain the operator.
  • __lt__ unlocks sorted() and min(); __len__ unlocks len().
  • Prefer the operator (a + b) over calling the dunder (a.__add__(b)) directly.

Next you will package several related classes into a module of your own and reuse code the way the standard library does.

Checkpoint quiz

Which dunder method does print(obj) use to decide what text to show?

Writing a + b for two objects of your class calls which method on a?

You want sorted(my_objects) to work with no key= argument. Which dunder must you define?

Go deeper — technical resources