The Story That Explains Operator Overloading
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.
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.
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
Animated Diagram — v1 + v2 Becomes v1.__add__(v2)
Every +, -, *, == in Python is a shortcut for a dunder method. Define the dunder → the operator works on your class.
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.
| Operator | Dunder Method | What 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)
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.
| Operator | Dunder Method | Notes |
|---|---|---|
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)]
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.
__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')]
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)
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.
Every arithmetic operator has an r-version. __radd__, __rsub__, __rmul__… same rule, opposite side.
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'])
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.
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
Every expression on the left routes through Python's central translator to the correct dunder on the right. Same object — many faces.
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)
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.
Complete Operator → Dunder Reference
| Category | Operator / Function | Dunder Method |
|---|---|---|
| Arithmetic | a + 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__ | |
| Comparison | a == b | __eq__ |
a != b | __ne__ (derived automatically) | |
a < b | __lt__ | |
a <= b | __le__ | |
a > b | __gt__ | |
a >= b | __ge__ | |
| In-place | a += b | __iadd__ |
a -= b | __isub__ | |
a *= b | __imul__ | |
a /= b | __itruediv__ | |
| Display | print(a), str(a) | __str__ |
| REPL, debuggers | __repr__ | |
f"{a:.2f}" | __format__ | |
| Container | len(a) | __len__ |
a[i] | __getitem__ | |
x in a | __contains__ | |
for x in a | __iter__ | |
| Misc | bool(a) | __bool__ |
hash(a) | __hash__ | |
a(...) | __call__ |
Common Mistakes (and Fixes)
| Mistake | What Goes Wrong | Fix |
|---|---|---|
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 |
Golden Rules
a + b is
a.__add__(b). a == b is a.__eq__(b). Define the
dunder — the operator lights up.
+ on Employee just because you can — readers won't know what to expect.
__add__,
__mul__). Leave the original untouched. In-place operators
(__iadd__) mutate and return self.
NotImplemented — never False, never raise
NotImplementedError. Python will try the reflected operator on the other side.
__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>.
__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__).
__lt__ and __eq__, then
slap @functools.total_ordering on the class. That gives you
<=, >, >= for free without four extra methods.
__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.
Money("USD") + Money("INR") should raise, not
silently coerce. The dunder is the last gate before your invariants are broken.
a + b immediately guess what happens?" If yes → overload it. If no →
write a plain method (a.combine(b)) instead. Clarity beats cleverness.