✨ Lesson 3: Magic Methods & Operator Overloading
Teach your classes to work with Python's built-in operations — print(), len(), ==, <, +, and more — by implementing special "dunder" methods.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what magic (dunder) methods are and when Python calls them
- Implement
__str__and__repr__for human-readable and debug-friendly output - Make objects support
len()with__len__ - Overload comparison operators with
__eq__and__lt__ - Overload arithmetic operators like
+with__add__
Estimated Time: 60 minutes
Project: Build a Money class that supports printing, comparisons, and arithmetic
In This Lesson
🪄 What Are Magic Methods?
You've already used one magic method — __init__. It's called automatically when you create an object. Python has dozens of these special methods (also called dunder methods because of the double underscore) that hook into built-in operations.
When you write print(obj), Python actually calls obj.__str__(). When you write len(obj), Python calls obj.__len__(). When you write a == b, Python calls a.__eq__(b).
By implementing these methods in your classes, you make your objects behave like built-in types — clean, intuitive, and Pythonic.
📖 Key Terms
Magic method (dunder method): A method with double underscores on both sides (e.g., __str__) that Python calls automatically in certain situations.
Operator overloading: Defining what operators like +, ==, < do for your custom objects.
Protocol: An informal interface — if your object implements the right dunder methods, Python treats it as that kind of thing (printable, comparable, iterable, etc.).
🧠 Why "Magic"?
They're called "magic" because you never call them directly (you wouldn't write obj.__str__() — you write str(obj) or print(obj)). Python calls them behind the scenes, which feels a bit magical. But there's nothing mysterious about them — they're just regular methods with special names.
🖨️ __str__ vs __repr__
These two methods both return strings, but they serve different audiences:
| Method | Called By | Audience | Goal |
|---|---|---|---|
__str__ |
print(), str(), f-strings |
End users | Readable, friendly output |
__repr__ |
REPL, repr(), debuggers, containers |
Developers | Unambiguous, recreatable output |
Without Either Method
If you don't define __str__ or __repr__, you get Python's default — the class name and memory address, which is useless:
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
p = Product("Widget", 9.99)
print(p)
Output:
<__main__.Product object at 0x7f3b2c1a5e10>
Adding __repr__
The convention for __repr__ is to return a string that looks like a valid Python expression that could recreate the object:
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def __repr__(self):
return f"Product({self.name!r}, {self.price!r})"
p = Product("Widget", 9.99)
print(repr(p)) # Developer view
print(p) # Also uses __repr__ since __str__ isn't defined
Output:
Product('Widget', 9.99)
Product('Widget', 9.99)
🧠 The !r Format Spec
Inside an f-string, {self.name!r} applies repr() to the value. For strings, this adds the surrounding quotes — so you get Product('Widget', 9.99) instead of Product(Widget, 9.99). This makes the output copy-pasteable as actual Python code.
Adding __str__
Now add a friendlier version for end users:
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def __repr__(self):
return f"Product({self.name!r}, {self.price!r})"
def __str__(self):
return f"{self.name} — ${self.price:.2f}"
p = Product("Widget", 9.99)
print(p) # Uses __str__ → friendly
print(repr(p)) # Uses __repr__ → developer-facing
print(f"Item: {p}") # f-strings use __str__
Output:
Widget — $9.99
Product('Widget', 9.99)
Item: Widget — $9.99
Fallback Behavior
If you only implement one, implement __repr__. Python falls back to __repr__ when __str__ isn't defined, but not the other way around.
⚠️ Containers Use __repr__
When objects are inside a list or dict and you print the container, Python uses __repr__ for each element — not __str__.
products = [Product("Widget", 9.99), Product("Gadget", 24.50)]
print(products)
# [Product('Widget', 9.99), Product('Gadget', 24.50)] ← __repr__
📏 __len__ — Making Objects Measurable
The built-in len() function works on lists, strings, and dicts because they all implement __len__. You can do the same for your classes:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
def __repr__(self):
return f"Playlist({self.name!r}, {len(self)} songs)"
def __str__(self):
return f"🎵 {self.name} ({len(self)} songs)"
playlist = Playlist("Road Trip")
playlist.add("Bohemian Rhapsody")
playlist.add("Hotel California")
playlist.add("Stairway to Heaven")
print(len(playlist)) # 3
print(playlist) # 🎵 Road Trip (3 songs)
Output:
3
🎵 Road Trip (3 songs)
__len__ must return a non-negative integer. Once you define it, your object also works with bool() — an empty container is falsy, a non-empty one is truthy:
empty = Playlist("Empty")
full = Playlist("Full")
full.add("Some Song")
print(bool(empty)) # False — len is 0
print(bool(full)) # True — len is 1
⚖️ __eq__ — Equality Comparison
By default, == checks if two variables point to the exact same object in memory (identity). That's almost never what you want for custom classes:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
a = Point(3, 4)
b = Point(3, 4)
print(a == b) # False! They're different objects
Implement __eq__ to define what "equal" means for your class:
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 __repr__(self):
return f"Point({self.x}, {self.y})"
a = Point(3, 4)
b = Point(3, 4)
c = Point(1, 2)
print(a == b) # True — same coordinates
print(a == c) # False — different coordinates
print(a == "hello") # False — NotImplemented lets Python handle it
Output:
True
False
False
📖 Returning NotImplemented
When other is an incompatible type, return NotImplemented (not raise NotImplementedError). This is a special singleton that tells Python: "I don't know how to compare with this type — try the other object's __eq__ instead." If neither side can handle it, Python returns False.
⚠️ __eq__ Breaks Default Hashing
When you define __eq__, Python automatically sets __hash__ to None, making your objects unhashable (can't be used as dict keys or in sets). If you need hashing, implement __hash__ too — but only for immutable objects. We'll touch on this in the best practices section.
defined?"} B -->|Yes| C{"Returns
NotImplemented?"} B -->|No| F["Compare by identity
(same object in memory)"] C -->|No| D["✅ Use the result"] C -->|Yes| E{"b.__eq__(a)
defined?"} E -->|Yes| G["Use b's result"] E -->|No| F
📊 __lt__ and Ordering
__lt__ defines the < operator for your objects. This is especially powerful because once you have __lt__, Python's sorted() and min()/max() automatically work:
class Student:
def __init__(self, name, gpa):
self.name = name
self.gpa = gpa
def __repr__(self):
return f"Student({self.name!r}, gpa={self.gpa})"
def __eq__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.gpa == other.gpa
def __lt__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.gpa < other.gpa
students = [
Student("Alice", 3.9),
Student("Bob", 3.2),
Student("Carlos", 3.7),
]
# sorted() uses __lt__ under the hood
for s in sorted(students):
print(s)
print(f"\nTop student: {max(students)}")
Output:
Student('Bob', gpa=3.2)
Student('Carlos', gpa=3.7)
Student('Alice', gpa=3.9)
Top student: Student('Alice', gpa=3.9)
The Full Set of Comparison Operators
| Operator | Magic Method | Meaning |
|---|---|---|
== |
__eq__(self, other) |
Equal to |
!= |
__ne__(self, other) |
Not equal to |
< |
__lt__(self, other) |
Less than |
<= |
__le__(self, other) |
Less than or equal |
> |
__gt__(self, other) |
Greater than |
>= |
__ge__(self, other) |
Greater than or equal |
The @functools.total_ordering Shortcut
Implementing all six is tedious. Python's functools module has a decorator that fills in the rest if you define __eq__ and one ordering method (__lt__):
from functools import total_ordering
@total_ordering
class Student:
def __init__(self, name, gpa):
self.name = name
self.gpa = gpa
def __eq__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.gpa == other.gpa
def __lt__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.gpa < other.gpa
def __repr__(self):
return f"Student({self.name!r}, gpa={self.gpa})"
a = Student("Alice", 3.9)
b = Student("Bob", 3.2)
# All of these now work — even though we only defined __eq__ and __lt__
print(a > b) # True (derived from __lt__)
print(a >= b) # True (derived from __lt__ and __eq__)
print(a <= b) # False (derived from __lt__ and __eq__)
print(a != b) # True (derived from __eq__)
Output:
True
True
False
True
✅ When to Use @total_ordering
Use it whenever your objects have a natural ordering and you want the full comparison suite without boilerplate. Just define __eq__ + one of __lt__, __gt__, __le__, or __ge__, and the decorator handles the rest.
➕ Arithmetic Operators — __add__ and Friends
You can also define what happens when someone uses +, -, *, and other arithmetic operators on your objects. Let's build a Vector class:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({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 __sub__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
if not isinstance(scalar, (int, float)):
return NotImplemented
return Vector(self.x * scalar, self.y * scalar)
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __abs__(self):
"""abs(v) returns the magnitude (length) of the vector."""
return (self.x ** 2 + self.y ** 2) ** 0.5
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2) # Vector(4, 6)
print(v1 - v2) # Vector(2, 2)
print(v1 * 3) # Vector(9, 12)
print(abs(v1)) # 5.0
print(v1 == Vector(3, 4)) # True
Output:
Vector(4, 6)
Vector(2, 2)
Vector(9, 12)
5.0
True
Arithmetic Magic Methods Reference
| Operator | Magic Method | Reverse Method |
|---|---|---|
+ |
__add__ |
__radd__ |
- |
__sub__ |
__rsub__ |
* |
__mul__ |
__rmul__ |
/ |
__truediv__ |
__rtruediv__ |
// |
__floordiv__ |
__rfloordiv__ |
** |
__pow__ |
__rpow__ |
abs() |
__abs__ |
— |
-obj (negation) |
__neg__ |
— |
🧠 What Are "Reverse" Methods?
When you write v1 * 3, Python calls v1.__mul__(3). But what about 3 * v1? The integer 3 doesn't know about your Vector class, so its __mul__ returns NotImplemented. Python then tries the right operand's reverse method: v1.__rmul__(3).
def __rmul__(self, scalar):
return self.__mul__(scalar) # Multiplication is commutative for scaling
🔘 __bool__ — Truthiness
Python calls __bool__ when it needs a truth value — in if statements, while loops, and/or/not expressions, and bool().
If __bool__ isn't defined, Python falls back to __len__ (zero = falsy, non-zero = truthy). If neither exists, the object is always truthy.
class ShoppingCart:
def __init__(self):
self.items = []
def add(self, item, price):
self.items.append({"item": item, "price": price})
def __len__(self):
return len(self.items)
def __bool__(self):
"""Cart is truthy only if it has items AND total > 0."""
return len(self.items) > 0 and self.total() > 0
def total(self):
return sum(item["price"] for item in self.items)
def __repr__(self):
return f"ShoppingCart({len(self)} items, ${self.total():.2f})"
cart = ShoppingCart()
print(bool(cart)) # False — empty
cart.add("Sticker", 0.00)
print(bool(cart)) # False — has items but total is 0
cart.add("Book", 15.99)
print(bool(cart)) # True — has items and total > 0
if cart:
print(f"Ready to checkout: {cart}")
else:
print("Cart is empty or has no value")
Output:
False
False
True
Ready to checkout: ShoppingCart(2 items, $15.99)
defined?"} B -->|Yes| C["Use __bool__()"] B -->|No| D{"__len__
defined?"} D -->|Yes| E["len() == 0 → False
len() > 0 → True"] D -->|No| F["Always True"]
🏋️ Hands-on Exercises
🏋️ Exercise 1: Temperature Class
Objective: Practice __str__, __repr__, __eq__, and __lt__.
Requirements:
- Create a
Temperatureclass that stores a value in Celsius - Implement
__repr__to returnTemperature(25.0) - Implement
__str__to return25.0°C (77.0°F) - Implement
__eq__and__lt__(compare by Celsius value) - Add a
to_fahrenheit()method (formula:celsius * 9/5 + 32) - Add a class method
from_fahrenheit(f)that creates a Temperature from °F
Starter Code:
from functools import total_ordering
@total_ordering
class Temperature:
def __init__(self, celsius):
# TODO
pass
def to_fahrenheit(self):
# TODO
pass
@classmethod
def from_fahrenheit(cls, f):
# TODO: convert f to celsius, return Temperature(celsius)
pass
def __repr__(self):
# TODO: Temperature(25.0)
pass
def __str__(self):
# TODO: 25.0°C (77.0°F)
pass
def __eq__(self, other):
# TODO
pass
def __lt__(self, other):
# TODO
pass
# Test
t1 = Temperature(100)
t2 = Temperature.from_fahrenheit(212)
t3 = Temperature(0)
print(t1) # 100.0°C (212.0°F)
print(repr(t2)) # Temperature(100.0)
print(t1 == t2) # True
print(t3 < t1) # True
print(sorted([t1, t3, Temperature(50)]))
💡 Hint
For from_fahrenheit, the inverse formula is (f - 32) * 5/9. Store celsius as a float using float(celsius) so formatting is consistent.
✅ Solution
from functools import total_ordering
@total_ordering
class Temperature:
def __init__(self, celsius):
self.celsius = float(celsius)
def to_fahrenheit(self):
return self.celsius * 9 / 5 + 32
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
def __repr__(self):
return f"Temperature({self.celsius})"
def __str__(self):
return f"{self.celsius}°C ({self.to_fahrenheit()}°F)"
def __eq__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius == other.celsius
def __lt__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius < other.celsius
t1 = Temperature(100)
t2 = Temperature.from_fahrenheit(212)
t3 = Temperature(0)
print(t1) # 100.0°C (212.0°F)
print(repr(t2)) # Temperature(100.0)
print(t1 == t2) # True
print(t3 < t1) # True
print(sorted([t1, t3, Temperature(50)]))
# [Temperature(0.0), Temperature(50.0), Temperature(100.0)]
🏋️ Exercise 2: Money Class
Objective: Practice __add__, __mul__, __str__, __eq__, __lt__, and __len__.
Requirements:
- Create a
Moneyclass withamount(float) andcurrency(string, default"USD") - Implement
__str__to return$25.00 USD - Implement
__repr__to returnMoney(25.0, 'USD') - Implement
__add__— only allow adding Money with the same currency (raiseValueErrorotherwise) - Implement
__mul__for scalar multiplication (e.g.,price * 3) - Implement
__eq__and__lt__(same-currency only) - Implement
__bool__— falsy if amount is zero or negative
💡 Hint
In __add__, check self.currency == other.currency first. Use round(amount, 2) when creating new Money objects to avoid floating-point drift.
✅ Solution
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, amount, currency="USD"):
self.amount = round(float(amount), 2)
self.currency = currency
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
def __str__(self):
return f"${self.amount:.2f} {self.currency}"
def _check_currency(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError(
f"Cannot operate on {self.currency} and {other.currency}"
)
def __add__(self, other):
check = self._check_currency(other)
if check is NotImplemented:
return check
return Money(self.amount + other.amount, self.currency)
def __mul__(self, scalar):
if not isinstance(scalar, (int, float)):
return NotImplemented
return Money(self.amount * scalar, self.currency)
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other):
check = self._check_currency(other)
if check is NotImplemented:
return check
return self.amount < other.amount
def __bool__(self):
return self.amount > 0
# Test
price = Money(25)
tax = Money(2.25)
total = price + tax
print(total) # $27.25 USD
print(total * 3) # $81.75 USD
print(2 * price) # $50.00 USD
print(price == Money(25)) # True
print(tax < price) # True
print(bool(Money(0))) # False
# This raises ValueError:
# eur = Money(10, "EUR")
# price + eur → ValueError: Cannot operate on USD and EUR
🎯 Quick Quiz
Question 1: What's the difference between __str__ and __repr__?
Question 2: Why should you return NotImplemented (not raise NotImplementedError) in comparison methods when the other type is incompatible?
Question 3: If a class defines __len__ but not __bool__, what does bool(obj) return?
📏 Best Practices
✅ Do's
- Always implement
__repr__— it's the minimum for debuggability. Make it look like a valid constructor call when possible. - Return
NotImplementedfor unsupported operand types — don't raise errors or returnFalse - Use
@total_orderingwhen you need full comparison support — define__eq__+ one ordering method - Return new objects from arithmetic methods —
__add__should return a new instance, not modifyself - Implement
__rmul__if you implement__mul__with scalars — so bothv * 3and3 * vwork
❌ Don'ts
- Don't use magic methods to do surprising things —
__add__should add, not delete items or send emails - Don't forget
__hash__when you define__eq__— if your objects are mutable, set__hash__ = Noneexplicitly. If immutable, implement a consistent hash. - Don't compare incompatible types silently — return
NotImplementedor raise a clear error
💡 Pro Tips
__repr__uses!rformat spec for string attributes:f"Product({self.name!r})"outputsProduct('Widget')with proper quotes- The
__hash__rule: objects that compare equal must have the same hash. A safe pattern for immutable objects:def __hash__(self): return hash((self.x, self.y)) - You can chain magic methods: a
__getitem__method lets your object supportobj[key], and__iter__makes it work inforloops — you'll see these in later lessons
📝 Summary
🎉 Key Takeaways
- Magic methods are special
__dunder__methods that Python calls automatically for built-in operations __str__gives user-friendly output;__repr__gives developer-friendly output (always implement__repr__)__len__enableslen()and provides a truthiness fallback__eq__defines equality; returnNotImplementedfor incompatible types__lt__enables<and powerssorted()/min()/max()@total_orderingfills in all comparison operators from__eq__+ one ordering method__add__,__mul__and friends let your objects work with arithmetic operators__bool__controls truthiness; falls back to__len__if not defined
| Operation | Magic Method | Example |
|---|---|---|
print(obj) |
__str__ |
"Widget — $9.99" |
repr(obj) |
__repr__ |
"Product('Widget', 9.99)" |
len(obj) |
__len__ |
3 |
a == b |
__eq__ |
True |
a < b |
__lt__ |
True |
a + b |
__add__ |
Vector(4, 6) |
a * 3 |
__mul__ |
Vector(9, 12) |
bool(obj) |
__bool__ |
True |
📚 Additional Resources
- Python Docs — Special Method Names
- Python Docs — functools.total_ordering
- Real Python — Operator & Function Overloading
🚀 What's Next?
In the next lesson, we'll move into Module 2: Working with Data and tackle File I/O & Context Managers — reading and writing files, the with statement, and building your own context managers to manage resources cleanly.
🎉 Module 1 Complete!
You've mastered the three pillars of OOP in Python — classes, inheritance, and magic methods. Your objects can now be printed, compared, sorted, and used with built-in operators just like native Python types.