Intermediate Python 📂 Class and Object · 8 of 10 47 min read

Python OOP — Operator Overloading

Master Python operator overloading — let your custom classes work with +, -, *, /, ==, <, and every built-in operator. Learn arithmetic, comparison, reflected, and in-place dunder methods (__add__, __eq__, __lt__, __radd__, __iadd__) through a Vector and Money class example, three animated SVG diagrams (dispatch, reflected operators, full suite), a complete operator → dunder reference, common pitfalls, and 10 golden rules.

Section 01

The Story That Explains Operator Overloading

The Word "Add" — Same Symbol, Many Meanings
Look at the humble + sign. It's a single character, but it means totally different things depending on what's being combined:

5 + 3 → 8   (arithmetic addition)
"hello" + "world" → "helloworld"   (string concatenation)
[1, 2] + [3, 4] → [1, 2, 3, 4]   (list joining)
date(2026,1,1) + timedelta(days=7) → January 8th   (calendar shift)

Every one of these is Python secretly calling a different method under the covers. 5.__add__(3). "hello".__add__("world"). Same operator name — but the real behaviour lives inside each type.

Operator overloading is you claiming that same power for your own classes. You define __add__ on a Vector class, and suddenly v1 + v2 just works. You define __eq__, and v1 == v2 does the right thing. Python's built-in operator syntax becomes your syntax.
🔭
The Core Insight

Every Python operator — +, -, *, ==, <, even len() and print() — is really just syntactic sugar for a dunder method call (short for "double underscore," e.g. __add__, __eq__). Define the right dunder on your class, and the operator lights up automatically.


Section 02

How + Becomes a Method Call

Every time Python sees a + b, it doesn't magically know how to add — it looks up a.__add__(b) and runs that. For built-in types Python defines these for you. For your own class, you define them.

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

    # ── Define what + means for two Vectors ──
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    # ── Define how a Vector prints ──
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"


v1 = Vector(2, 3)
v2 = Vector(4, 5)

# These two lines are IDENTICAL to Python
print(v1 + v2)             # Vector(6, 8)
print(v1.__add__(v2))     # Vector(6, 8)   ← same call under the hood
OUTPUT
Vector(6, 8) Vector(6, 8)

Animated Diagram — v1 + v2 Becomes v1.__add__(v2)

OPERATOR → DUNDER DISPATCH
WHAT YOU WRITE v1 + v2 Python translates WHAT PYTHON RUNS v1.__add__(v2) YOUR __add__ RUNS return Vector(self.x + other.x,               self.y + other.y) Vector(6, 8)

Every +, -, *, == in Python is a shortcut for a dunder method. Define the dunder → the operator works on your class.


Section 03

Arithmetic Operators

The five most common arithmetic operators each have a matching dunder. Define the ones you need — no rule says every class must support every operator.

OperatorDunder MethodWhat It Does
a + b__add__Addition
a - b__sub__Subtraction
a * b__mul__Multiplication
a / b__truediv__Division (float result)
a // b__floordiv__Floor division
a % b__mod__Modulo
a ** b__pow__Exponentiation
-a__neg__Unary negation
abs(a)__abs__Absolute value / magnitude
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

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

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        # Scalar multiplication: Vector * number
        return Vector(self.x * scalar, self.y * scalar)

    def __neg__(self):
        return Vector(-self.x, -self.y)

    def __abs__(self):
        # Magnitude — Euclidean length
        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(-v1)         # Vector(-3, -4)
print(abs(v1))    # 5.0  (magnitude of a 3-4-5 triangle)
OUTPUT
Vector(4, 6) Vector(2, 2) Vector(9, 12) Vector(-3, -4) 5.0

Section 04

Comparison Operators

The six comparison operators map one-to-one to their dunder methods. If you define __eq__, Python figures out != automatically. Define __lt__ and pair it with @functools.total_ordering to get every other comparison for free.

OperatorDunder MethodNotes
a == b__eq__Also enables != automatically
a != b__ne__Rarely needed — __eq__ is enough
a < b__lt__Used by sorted()
a <= b__le__
a > b__gt__
a >= b__ge__
from functools import total_ordering

@total_ordering                       # fills in <=, >, >= from __lt__ + __eq__
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

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

    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    # ── Two vectors are equal if both components match ──
    def __eq__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    # ── Order by magnitude ──
    def __lt__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return abs(self) < abs(other)


a = Vector(3, 4)   # magnitude 5
b = Vector(3, 4)   # magnitude 5 — equal
c = Vector(6, 8)   # magnitude 10

print(a == b)          # True
print(a != c)          # True   (Python derives this from __eq__)
print(a < c)           # True   (magnitude 5 < 10)
print(a >= b)          # True   (@total_ordering filled this in)

# Because __lt__ exists, sorted() works
print(sorted([c, a, Vector(0, 1)]))
# [Vector(0, 1), Vector(3, 4), Vector(6, 8)]
💡
Why NotImplemented Instead of False?

When your __eq__ is passed something that isn't a Vector, return NotImplemented (the singleton — not False, and not a raised NotImplementedError). This tells Python "I don't know how to compare with that type, try the other side." Returning False means "they are definitively not equal," which lies to Python and breaks reflexivity.


Section 05

__str__ vs __repr__ — Customising Display

These two dunders control how your object is printed. They serve different audiences: __str__ is for end users (shown by print()); __repr__ is for developers (shown in the REPL, debuggers, and error messages). If you define only one, define __repr__str falls back to it automatically.

class Money:
    def __init__(self, amount, currency="USD"):
        self.amount   = amount
        self.currency = currency

    # ── Developer-facing: unambiguous, ideally could rebuild the object ──
    def __repr__(self):
        return f"Money({self.amount!r}, {self.currency!r})"

    # ── End-user-facing: pretty, readable ──
    def __str__(self):
        return f"{self.currency} {self.amount:,.2f}"


m = Money(1234.5, "INR")

print(m)            # INR 1,234.50               ← calls __str__
print(str(m))       # INR 1,234.50               ← calls __str__
print(repr(m))      # Money(1234.5, 'INR')       ← calls __repr__

# In a list, the REPL shows repr — that's why it looks debuggy
print([m])          # [Money(1234.5, 'INR')]
OUTPUT
INR 1,234.50 INR 1,234.50 Money(1234.5, 'INR') [Money(1234.5, 'INR')]

Section 06

Reflected Operators — When the Custom Object Is on the Right Side

Consider 3 * v1. Python first tries int.__mul__(3, v1) — and int has no idea what a Vector is, so it returns NotImplemented. Then Python asks the right-hand side: "do you have a reflected version of this operator?" That's __rmul__. Every arithmetic operator has an r-version for exactly this case.

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

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

    def __mul__(self, scalar):
        print("  [__mul__ called]")
        return Vector(self.x * scalar, self.y * scalar)

    def __rmul__(self, scalar):
        # Called when the vector is on the RIGHT of the *
        print("  [__rmul__ called]")
        return self.__mul__(scalar)


v = Vector(2, 3)

print(v * 4)              # uses __mul__ → Vector(8, 12)
print()
print(4 * v)              # uses __rmul__ → Vector(8, 12)
OUTPUT
[__mul__ called] Vector(8, 12) [__rmul__ called] [__mul__ called] Vector(8, 12)

Animated Diagram — Reflected Dispatch

Watch 3 + v1. Python asks int first — it can't. Then Python asks the Vector's __radd__ — and gets an answer. That two-step fallback is what lets you write arithmetic in either order.

REFLECTED DISPATCH  ·  INT SAYS "NO" → VECTOR SAYS "YES"
3 + v1 STEP 1  ·  PYTHON ASKS THE INT int.__add__(3, v1) "I don't know what a Vector is..." → returns NotImplemented Python retries STEP 2  ·  PYTHON ASKS THE VECTOR'S __radd__ v1.__radd__(3) "Sure, I'll add 3 to both components" → returns Vector(...) RESULT Vector(5, 6)

Every arithmetic operator has an r-version. __radd__, __rsub__, __rmul__… same rule, opposite side.


Section 07

In-place Operators — +=, -=, *=

When you write v += w, Python first tries v.__iadd__(w). If your class doesn't define __iadd__, Python falls back to v = v + w. The choice is deliberate: in-place operators should mutate the object in place and return self. Immutable classes (like Money or a math Vector) should not define these — the fallback creates a new object, which is the right behaviour.

class Playlist:
    """Mutable — in-place operations modify self."""
    def __init__(self, songs=None):
        self.songs = songs or []

    def __repr__(self):
        return f"Playlist({self.songs})"

    def __iadd__(self, song):
        # Mutate in place, return self
        self.songs.append(song)
        return self


mix = Playlist(["Track 1"])
mix += "Track 2"                # calls __iadd__
mix += "Track 3"
print(mix)                      # Playlist(['Track 1', 'Track 2', 'Track 3'])
⚠️
Return self from __iadd__

Forgetting to return self from __iadd__ makes x += y silently set x to None. The rule: mutate the object then return self. Every in-place dunder follows the same pattern.


Section 08

Complete Practical Example — Vector Class

from functools import total_ordering


@total_ordering
class Vector:
    """An immutable 2D vector with a full operator suite."""

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

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

    # ── Arithmetic ──────────────────────────────────────────
    def __add__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y)
        return NotImplemented

    def __sub__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x - other.x, self.y - other.y)
        return NotImplemented

    def __mul__(self, scalar):
        if isinstance(scalar, (int, float)):
            return Vector(self.x * scalar, self.y * scalar)
        return NotImplemented

    def __rmul__(self, scalar):
        # Handles: number * Vector
        return self.__mul__(scalar)

    def __truediv__(self, scalar):
        if isinstance(scalar, (int, float)):
            return Vector(self.x / scalar, self.y / scalar)
        return NotImplemented

    # ── Unary ───────────────────────────────────────────────
    def __neg__(self):
        return Vector(-self.x, -self.y)

    def __abs__(self):
        # Euclidean magnitude
        return (self.x ** 2 + self.y ** 2) ** 0.5

    # ── Comparison (total_ordering fills the rest from these two) ──
    def __eq__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        # Compare by magnitude
        if not isinstance(other, Vector):
            return NotImplemented
        return abs(self) < abs(other)

    # ── Hashable, so it works in sets/dicts (immutable class) ──
    def __hash__(self):
        return hash((self.x, self.y))


# ── Every operator you defined is now usable ──────────────
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(3 * v1)       # Vector(9, 12)   ← via __rmul__
print(v1 / 2)       # Vector(1.5, 2.0)
print(-v1)          # Vector(-3, -4)
print(abs(v1))     # 5.0

print(v1 == Vector(3, 4))     # True
print(v1 > v2)                # True (magnitude 5 > magnitude ~2.24)

# Sortable — because __lt__ is defined
vectors = [Vector(6, 8), v1, v2, Vector(0, 1)]
print(sorted(vectors))
# [Vector(0, 1), Vector(1, 2), Vector(3, 4), Vector(6, 8)]

# Hashable — usable in sets and dicts
print({Vector(1, 2), Vector(1, 2), Vector(3, 4)})
# {Vector(1, 2), Vector(3, 4)}    ← duplicates removed

Animated Diagram — Every Expression Fires a Different Dunder

MANY OPERATORS  ·  ONE OBJECT  ·  EACH FIRES ITS OWN DUNDER
WHAT YOU WRITE v1 + v2 3 * v1 -v1 abs(v1) v1 == v2 v1 < v2 print(v1) PYTHON translates each operator → dunder WHAT RUNS __add__ __rmul__ __neg__ __abs__ __eq__ __lt__ __repr__

Every expression on the left routes through Python's central translator to the correct dunder on the right. Same object — many faces.


Section 09

Complete Practical Example — Money Class

class Money:
    """Immutable currency-aware amount."""

    def __init__(self, amount, currency="USD"):
        self.amount   = float(amount)
        self.currency = currency

    # ── Display ─────────────────────────────────────────────
    def __repr__(self):
        return f"Money({self.amount!r}, {self.currency!r})"

    def __str__(self):
        return f"{self.currency} {self.amount:,.2f}"

    # ── Same-currency arithmetic ────────────────────────────
    def _check_currency(self, other):
        if self.currency != other.currency:
            raise ValueError(
                f"Cannot combine {self.currency} and {other.currency} directly"
            )

    def __add__(self, other):
        if isinstance(other, Money):
            self._check_currency(other)
            return Money(self.amount + other.amount, self.currency)
        return NotImplemented

    def __sub__(self, other):
        if isinstance(other, Money):
            self._check_currency(other)
            return Money(self.amount - other.amount, self.currency)
        return NotImplemented

    # ── Scalar multiplication (interest, tax, discount) ─────
    def __mul__(self, factor):
        if isinstance(factor, (int, float)):
            return Money(self.amount * factor, self.currency)
        return NotImplemented

    def __rmul__(self, factor):
        return self.__mul__(factor)

    # ── Comparison — only within the same currency ──────────
    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):
        if not isinstance(other, Money):
            return NotImplemented
        self._check_currency(other)
        return self.amount < other.amount


# ── Use it naturally, like a built-in numeric type ────────
salary = Money(50000, "INR")
bonus  = Money(10000, "INR")
rent   = Money(15000, "INR")

total     = salary + bonus                # uses __add__
remaining = total - rent                  # uses __sub__
with_tax  = remaining * 1.05              # uses __mul__
with_tax2 = 1.05 * remaining              # uses __rmul__ (same result)

print("Total:     ", total)         # INR 60,000.00
print("Remaining: ", remaining)     # INR 45,000.00
print("With tax:  ", with_tax)      # INR 47,250.00

print("Equal? ", with_tax == with_tax2)   # True
print("Bonus < Rent?", bonus < rent)      # True

# Adding two currencies is caught at the arithmetic layer
try:
    Money(100, "USD") + Money(100, "INR")
except ValueError as e:
    print("Blocked:", e)
OUTPUT
Total: INR 60,000.00 Remaining: INR 45,000.00 With tax: INR 47,250.00 Equal? True Bonus < Rent? True Blocked: Cannot combine USD and INR directly
🏆
Why This Reads So Naturally

Every line — salary + bonus, remaining * 1.05, bonus < rent — reads exactly like arithmetic on a built-in float. But under the hood, currency validation runs on every operation. That's operator overloading earning its keep: expressive syntax on top, safe logic underneath.


Section 10

Complete Operator → Dunder Reference

CategoryOperator / FunctionDunder Method
Arithmetica + b__add__  ·  __radd__
a - b__sub__  ·  __rsub__
a * b__mul__  ·  __rmul__
a / b__truediv__  ·  __rtruediv__
a // b__floordiv__
a % b__mod__
a ** b__pow__
Unary-a__neg__
+a__pos__
abs(a)__abs__
Comparisona == b__eq__
a != b__ne__ (derived automatically)
a < b__lt__
a <= b__le__
a > b__gt__
a >= b__ge__
In-placea += b__iadd__
a -= b__isub__
a *= b__imul__
a /= b__itruediv__
Displayprint(a), str(a)__str__
REPL, debuggers__repr__
f"{a:.2f}"__format__
Containerlen(a)__len__
a[i]__getitem__
x in a__contains__
for x in a__iter__
Miscbool(a)__bool__
hash(a)__hash__
a(...)__call__

Section 11

Common Mistakes (and Fixes)

MistakeWhat Goes WrongFix
Returning False from __eq__ for unrelated types Breaks reflexivity — x == y and y == x may disagree Return NotImplemented (the singleton) instead — Python retries the other side
Raising NotImplementedError instead of returning NotImplemented Crashes on any mixed-type comparison; Python can't fall back The two look similar but do opposite things. Return the singleton, don't raise
Forgetting __hash__ after overriding __eq__ Your class becomes unhashable — can't be used in sets or dict keys Define __hash__ as well (only for immutable classes), or leave it default
Mutating and returning self in __add__ x + y should return a NEW object, not modify x Return a new instance. Only __iadd__ mutates in place
Forgetting return self in __iadd__ x += y silently assigns None to x Mutate, then return self. Every in-place dunder ends the same way
Overloading operators that don't make semantic sense Readers can't guess what employee + employee means — surprises everywhere Only overload when the operator has an obvious real-world meaning
Defining __lt__ but not __eq__ sorted() may work but == falls back to identity — subtle bugs Always define __eq__ alongside __lt__. Consider @total_ordering
Skipping __repr__ Debug prints show <Vector object at 0x7f...> — useless Always add __repr__ as soon as your class carries meaningful data

Section 12

Golden Rules

➕ Operator Overloading — Non-Negotiable Rules
1
Every Python operator is a disguised dunder call. a + b is a.__add__(b). a == b is a.__eq__(b). Define the dunder — the operator lights up.
2
Only overload operators when the meaning is obvious. Two vectors add like vectors. Two amounts compare like amounts. Don't overload + on Employee just because you can — readers won't know what to expect.
3
Return a new object from binary operators (__add__, __mul__). Leave the original untouched. In-place operators (__iadd__) mutate and return self.
4
When the other operand is a type you don't understand, return the singleton NotImplemented — never False, never raise NotImplementedError. Python will try the reflected operator on the other side.
5
Define __repr__ before anything else. It's the first thing you'll want during debugging. Aim for a form that could rebuild the object: Vector(3, 4), not <my vector>.
6
If you define __eq__, decide about __hash__. Immutable classes should also define __hash__ so they work in sets and dict keys. Mutable classes should leave __hash__ as None (auto when you override __eq__).
7
For ordering, define __lt__ and __eq__, then slap @functools.total_ordering on the class. That gives you <=, >, >= for free without four extra methods.
8
Reflected operators (__radd__, __rmul__) matter when the right operand is your custom class. 3 * v1 won't work unless Vector defines __rmul__. Add them for every operator you want to support in either order.
9
Validate inside the dunder. Money("USD") + Money("INR") should raise, not silently coerce. The dunder is the last gate before your invariants are broken.
10
When in doubt, ask: "Would a Python programmer looking at a + b immediately guess what happens?" If yes → overload it. If no → write a plain method (a.combine(b)) instead. Clarity beats cleverness.