AlgoMaster Logo

Operator Overloading

Medium Priority15 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

Operator overloading lets your own classes respond to the built-in operators (+, -, <, ==, abs(), and so on) by defining special dunder methods. Done well, it makes domain types like Money, Price, or CartItem read like the math they model. Done badly, it surprises everyone who touches the code. This chapter covers the operator dunders, when to use them, when to stop, and what Python does behind each operator call.

The Big Picture: Operators Are Method Calls

When you write a + b, Python doesn't perform a hard-coded addition. It calls a.__add__(b). If that method doesn't know what to do with b, Python falls back to b.__radd__(a) (the "reflected" version on the right operand). If neither works, Python raises TypeError.

Every operator follows this same dispatch shape. a < b calls a.__lt__(b). -a calls a.__neg__(). abs(a) calls a.__abs__(). Operator overloading is just defining the right dunder method on your class.

The diagram shows the full path Python walks for a + b. The left operand gets first dibs through __add__. If it returns the sentinel NotImplemented (we'll cover that shortly), Python tries the right operand's __radd__. Only when both bail does Python give up and raise TypeError. The same shape applies to every binary operator; only the method names change.

Arithmetic Operators

The six common arithmetic operators each map to one dunder method:

OperatorDunder MethodExample
+__add__a + b
-__sub__a - b
*__mul__a * b
/__truediv__a / b
//__floordiv__a // b
%__mod__a % b

Here's a Money class that adds, subtracts, and scales by a number. Each method returns a new Money object so the original values don't change.

Notice two design choices. First, the methods don't mutate self; they return a new Money. That's the standard behavior for +, -, and *, and it matches what built-in numbers do (2 + 3 doesn't change 2). Second, when the operand isn't valid (wrong currency, wrong type), the method returns NotImplemented instead of raising. We'll see why that matters in a moment.

The division operators work the same way. __truediv__ handles / (true division), and __floordiv__ handles // (floor division). __mod__ handles %.

True division divides exactly; floor division here rounds the per-person share down to whole cents. Whether // makes sense on Money depends on your domain. If it does, define it. If it doesn't, leave it out and let Python raise TypeError for you.

Reverse Arithmetic: When the Left Operand Doesn't Know You

Picture this expression: 2 * Money(19.99). The left operand is an int. Python first asks int.__mul__(2, Money(19.99)). The built-in int type has no idea what a Money object is, so its __mul__ returns NotImplemented. Python then asks the right operand: it calls Money(19.99).__rmul__(2). If that method exists and returns a value, you get the answer.

The r in __radd__, __rmul__, etc. stands for "reflected" or "reversed". You define these when you want your class to participate in operations where it appears on the right side and the left operand doesn't know about it.

The sum(cart) call works because of __radd__. sum() starts with 0 as the initial accumulator and then computes 0 + cart[0]. The left operand is an int, which doesn't know how to add a Money, so Python falls back to Money.__radd__(cart[0], 0). Our __radd__ treats 0 as the identity and returns self unchanged. After that, every step is Money + Money, which __add__ handles directly.

__rmul__ is even more common because writing 3 * item reads naturally for many people. Defining __rmul__ lets your class be on either side of *.

The rule of thumb: define the reflected method when you genuinely want to support int + YourThing or int * YourThing. If you don't define __radd__, that expression raises TypeError because neither operand knows what to do with the other.

In-Place Operators: When += Means More Than +

Python's += looks like = plus +, but it has its own dunder: __iadd__. The i stands for "in-place". If your class defines __iadd__, then a += b calls a.__iadd__(b) and rebinds a to whatever that returns. If your class doesn't define __iadd__, Python falls back to a = a + b using __add__.

The difference matters when your object is mutable. For numbers, += makes a new number, because numbers are immutable. For lists, += mutates the list in place. Your classes get to pick which behavior they want.

The crucial line is return self. Python takes whatever __iadd__ returns and rebinds the left-hand name to it. If you forget the return, my_cart += "hdmi" rebinds my_cart to None, and the next operation crashes. That's a classic bug.

If you skip __iadd__ entirely, += still works as long as __add__ is defined, but it creates a new object every time:

Without __iadd__, balance += Money(25.00) is just balance = balance + Money(25.00). The original object stays put; only the name balance gets rebound to a new Money. For immutable-feeling values like money, that's usually the behavior you want, so don't define __iadd__ on them.

Comparison Operators: Sorting Products by Price

The six comparison operators each have their own dunder:

OperatorDunder MethodWhat It Returns
==__eq__True / False
!=__ne__True / False
<__lt__True / False
<=__le__True / False
>__gt__True / False
>=__ge__True / False

The most useful one to define is __lt__, because it's what sorted() and list.sort() use under the hood. Combined with __eq__, it gives you objects that behave well in collections.

sorted() only needs __lt__ to work. It compares each pair with < and uses the result to decide order. If you define __eq__ too, equality checks (==, in, dict lookups) behave correctly as well.

A subtle but important rule: when you define __eq__, Python sets __hash__ to None automatically, which makes the class unhashable. If you want your objects to live in sets or be dict keys, you need to define __hash__ too, or inherit it from a parent. For now, know that __eq__ alone breaks hashing.

You don't have to define all six. Python fills in some pairings:

  • != defaults to not __eq__(), so you rarely need __ne__ explicitly.
  • > doesn't automatically derive from <. Python won't infer __gt__ from __lt__ unless you ask it to.

That last point is where functools.total_ordering comes in.

functools.total_ordering: Get All Six From Two

Writing all six comparison methods is tedious and error-prone. The total_ordering decorator in functools lets you define just __eq__ and one ordering method (typically __lt__), and it fills in the other three (__le__, __gt__, __ge__) for you.

We never wrote __gt__, __le__, or __ge__, but they work. The decorator implements them in terms of the two we did define. Internally, it does the obvious things: a > b is b < a, a <= b is a < b or a == b, and so on.

Why not always use total_ordering? Two reasons. First, the derived methods do more work per call, as the cost note above mentions. Second, the decorator can hide subtle bugs: if your __eq__ and __lt__ disagree (say, a < b is True but they also report equal), the derived methods inherit the inconsistency. The decorator doesn't check that your two methods are mutually consistent; that part is on you.

Unary Operators: Negation and Absolute Value

Unary operators take a single operand. The three you'll meet most often are:

Operator / BuiltinDunder MethodExample
-a__neg__-Money(10)
+a__pos__+Money(10)
abs(a)__abs__abs(Money(-10))

For a Money-style class, negation flips the sign (useful for refunds), abs returns the magnitude (useful for "how much did this transaction move"), and +a is usually a no-op identity.

__pos__ returning a fresh Money with the same amount looks pointless, and for most types it is. The reason it exists in Python at all is that +x can mean something different from x for certain types (the decimal.Decimal class uses +x to normalize precision, for example). For typical domain types, either leave __pos__ undefined or have it return self (or a copy).

Unary methods don't have reverse counterparts. There's only one operand, so there's nothing to reflect.

Returning NotImplemented Instead of Raising

Earlier we kept writing return NotImplemented whenever an operand looked wrong. That isn't the same as raising NotImplementedError, and it isn't the same as returning False. NotImplemented is a specific singleton (similar to None or True) that tells Python "I don't know how to handle this operand; please try the other side."

When a.__add__(b) returns NotImplemented, Python doesn't give up. It tries b.__radd__(a). Only if that also returns NotImplemented does Python finally raise TypeError.

Money.__add__ returned NotImplemented for the string operand. Python then tried str.__radd__, which also doesn't know about Money. With both sides giving up, Python raised the standard TypeError. Notice the error message is the one Python writes for you, not one you had to format. That's the payoff for returning NotImplemented properly.

Compare that with what happens when you raise instead:

In the expression 0 + BadMoney(10), Python first tries int.__add__(0, BadMoney(10)), which returns NotImplemented. Python then tries BadMoney.__radd__(BadMoney(10), 0), but we didn't define it, so that fails. The fallback chain ends cleanly. The lesson is the same on the other side: when Money + str happens, Money.__add__ must return NotImplemented so the dispatch can try str.__radd__ before failing. Raising too eagerly inside __add__ shortcuts that machinery.

The rule: any binary operator dunder that gets an operand it doesn't understand should return NotImplemented, not raise.

When Not to Overload Operators

The whole point of operator overloading is to make code read like the math or domain it models. The whole risk is the opposite: making code look like familiar math while actually doing something surprising. A few principles:

Don't overload `+` to mean something unrelated to addition or concatenation. If cart + product adds a product to the cart, that's reasonable, it reads like concatenation. If cart + product saves the cart to a database, hide that behind a regular method like save_with(product). The operator should be a hint about behavior, not a riddle.

Don't overload comparison operators for non-orderings. < between two products by price is fine because price is a total order. < between two customers by "who is more loyal" is suspicious if loyalty isn't a clear numeric ranking. When the ordering isn't natural, a method like is_more_loyal_than(other) reads better than customer_a < customer_b.

Don't overload an operator to mutate one operand and not the other. a + b should not change a. The whole expectation is that + returns a new value. If you want mutation, use += (which goes through __iadd__) or a named method like cart.add(item).

Be conservative with operator combinations that mix types. Supporting Money + Money is uncontroversial. Supporting Money + int opens a real question: does Money(10) + 5 mean Money(15), or is it an error because 5 has no currency? Pick the answer that won't surprise anyone, write it down, and apply it consistently.

A useful self-check: if a new teammate read the line total = price_a + price_b cold, would they assume the obvious meaning? If yes, the overload is earning its keep. If they'd have to read your class to know what's happening, the operator is hiding the behavior, and a named method would have served better.

The bad case is invented to illustrate the point; the principle is that operator names carry meanings, and those meanings come from arithmetic, sequences, and sets. Stretching them past those domains tends to lose readers more than it gains.

Operator Reference Table

A consolidated map of the operators covered in this chapter:

CategoryOperatorDunderReflectedIn-Place
Arithmetic+__add____radd____iadd__
Arithmetic-__sub____rsub____isub__
Arithmetic*__mul____rmul____imul__
Arithmetic/__truediv____rtruediv____itruediv__
Arithmetic//__floordiv____rfloordiv____ifloordiv__
Arithmetic%__mod____rmod____imod__
Comparison==__eq__(same)n/a
Comparison!=__ne__(same)n/a
Comparison<__lt__(uses __gt__)n/a
Comparison<=__le__(uses __ge__)n/a
Comparison>__gt__(uses __lt__)n/a
Comparison>=__ge__(uses __le__)n/a
Unary-a__neg__n/an/a
Unary+a__pos__n/an/a
Unaryabs(a)__abs__n/an/a

A few notes on the table. Comparison operators don't have separate reflected methods; Python uses the "swapped" comparison on the other operand instead (so a < b falling through tries b > a). In-place arithmetic methods are optional; if you don't define them, Python falls back to the regular __add__, __sub__, etc. and rebinds the name.

Quiz

Operator Overloading Quiz

10 quizzes