__add__ and __iadd__ define two distinct behaviors for addition in Python: one creates a new object, the other modifies the existing one. This distinction has real consequences for aliases and shared references, and it catches even experienced developers off guard.
add: the addition that creates
__add__ is called by the + operator. It must return a new object and leave the operands unchanged.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
a = Vector(1, 2)
b = Vector(3, 4)
c = a + b # new Vector(4, 6)
print(a.x, a.y) # 1 2 — a is unchanged
print(id(a) == id(c)) # False — distinct objects
a + b calls a.__add__(b). If __add__ is not defined on a or returns NotImplemented, Python tries b.__radd__(a).
iadd: the addition that mutates
__iadd__ is called by the += operator. It modifies the object in place and returns self.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __iadd__(self, other):
self.x += other.x
self.y += other.y
return self # mandatory
a = Vector(1, 2)
b = Vector(3, 4)
original_id = id(a)
a += b
print(a.x, a.y) # 4 6
print(id(a) == original_id) # True — same object
Returning self is non-negotiable: Python reassigns the result of __iadd__ to the left-hand variable. Forgetting return self is equivalent to writing a = None.
The alias trap
The copy/reference distinction becomes critical when multiple variables point to the same object.
a = Vector(1, 2)
alias = a # alias and a point to the same object
# With __add__: a + b creates a new Vector
a = a + Vector(3, 4)
print(id(a) == id(alias)) # False — a points to a new object
print(alias.x, alias.y) # 1 2 — alias still points to the original
# With __iadd__: a += b mutates in place
a = Vector(1, 2)
alias = a
a += Vector(3, 4)
print(id(a) == id(alias)) # True — same object
print(alias.x, alias.y) # 4 6 — alias sees the change
When an object is shared across multiple references (class attribute, function argument, list element), += with __iadd__ propagates the change to every access point.
The silent fallback
If __iadd__ is not defined, Python uses __add__ as a fallback for +=. The operation then creates a new object instead of mutating the existing one.
class Counter:
def __init__(self, n):
self.n = n
def __add__(self, other):
return Counter(self.n + other.n)
# no __iadd__
a = Counter(5)
original_id = id(a)
a += Counter(3)
print(id(a) == original_id) # False — new object, __add__ used as fallback
This behavior is correct but can be surprising: a += b acts like a = a + b rather than an in-place mutation.
Immutable types: += always copies
Immutable types (int, str, tuple) have no __iadd__. The += operator always creates a new object.
a = "hello"
original_id = id(a)
a += " world"
print(id(a) == original_id) # False — new string
x = 10
ref = x
x += 1
print(ref) # 10 — ref still points to the old int
Mutable types: += mutates in place
Mutable types like list have a __iadd__ that extends the list in place via extend(). The difference from + is not cosmetic.
a = [1, 2]
alias = a
a += [3] # calls list.__iadd__, mutates in place
print(alias) # [1, 2, 3] — alias sees the change
a = [1, 2]
alias = a
a = a + [3] # calls list.__add__, creates a new list
print(alias) # [1, 2] — alias still points to the old list
When to implement each
| Case | Recommendation |
|---|---|
| Immutable object by design | __add__ only |
| Mutable object with internal state | __add__ + __iadd__ |
__iadd__ without __add__ | Avoid: + becomes unusable |
If the object is mutable and in-place modification is semantically correct, implementing __iadd__ avoids unnecessary copies and makes the intent explicit.
