Skip to main content

✨ 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.).

flowchart LR A["print(obj)"] --> B["obj.__str__()"] C["len(obj)"] --> D["obj.__len__()"] E["a == b"] --> F["a.__eq__(b)"] G["a + b"] --> H["a.__add__(b)"] I["a < b"] --> J["a.__lt__(b)"] style B fill:#6366f1,color:#fff style D fill:#6366f1,color:#fff style F fill:#6366f1,color:#fff style H fill:#6366f1,color:#fff style J fill:#6366f1,color:#fff

🧠 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.

print(obj) __str__ ✅ Try first fallback __repr__ 🔄 Fallback Lists & dicts always use __repr__ for items

⚠️ 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.

flowchart TD A["a == b"] --> B{"a.__eq__(b)
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
a + b a.__add__(b) ① Try first if Not Implemented b.__radd__(a) ② Fallback

🔘 __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)
flowchart TD A["bool(obj)"] --> B{"__bool__
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:

  1. Create a Temperature class that stores a value in Celsius
  2. Implement __repr__ to return Temperature(25.0)
  3. Implement __str__ to return 25.0°C (77.0°F)
  4. Implement __eq__ and __lt__ (compare by Celsius value)
  5. Add a to_fahrenheit() method (formula: celsius * 9/5 + 32)
  6. 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:

  1. Create a Money class with amount (float) and currency (string, default "USD")
  2. Implement __str__ to return $25.00 USD
  3. Implement __repr__ to return Money(25.0, 'USD')
  4. Implement __add__ — only allow adding Money with the same currency (raise ValueError otherwise)
  5. Implement __mul__ for scalar multiplication (e.g., price * 3)
  6. Implement __eq__ and __lt__ (same-currency only)
  7. 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 NotImplemented for unsupported operand types — don't raise errors or return False
  • Use @total_ordering when you need full comparison support — define __eq__ + one ordering method
  • Return new objects from arithmetic methods__add__ should return a new instance, not modify self
  • Implement __rmul__ if you implement __mul__ with scalars — so both v * 3 and 3 * v work

❌ 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__ = None explicitly. If immutable, implement a consistent hash.
  • Don't compare incompatible types silently — return NotImplemented or raise a clear error

💡 Pro Tips

  • __repr__ uses !r format spec for string attributes: f"Product({self.name!r})" outputs Product('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 support obj[key], and __iter__ makes it work in for loops — 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__ enables len() and provides a truthiness fallback
  • __eq__ defines equality; return NotImplemented for incompatible types
  • __lt__ enables < and powers sorted() / min() / max()
  • @total_ordering fills 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

🚀 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.