__mul__ / __rmul__ / __imul__
What triggers each method
Python calls these dunder methods at specific moments during evaluation of * expressions:
| Expression | Method called | Notes |
|---|---|---|
a * b | type(a).__mul__(a, b) | First attempt |
a * b | type(b).__rmul__(b, a) | Reflected fallback if __mul__ returns NotImplemented |
a *= b | type(a).__imul__(a, b) | Augmented assignment |
The reflected call reverses the argument order — __rmul__ receives b as self and a as other. This is the key detail that trips up most implementations.
__mul__ — basic multiplication
__mul__ handles the left-hand side of a * b when a is an instance of your class. Python evaluates it as type(a).__mul__(a, b).
Return the result of the multiplication, or the singleton NotImplemented if your class doesn’t know how to handle b. Never raise NotImplementedError from a dunder — it’s an exception, not a signal, and will propagate as a crash rather than triggering the reflected fallback.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __mul__(self, other):
if isinstance(other, (int, float)):
return Vector(self.x * other, self.y * other)
return NotImplemented
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v = Vector(3, 4)
result = v * 2 # Vector.__mul__(v, 2) → Vector(6, 8)
print(result)
# output: Vector(6, 8)
__rmul__ — reflected multiplication and NotImplemented return
__rmul__ is the reflected counterpart. Python calls it when a.__mul__(b) returns NotImplemented — meaning the left side couldn’t handle the operation and is asking the right side to try.
The two conditions that trigger __rmul__:
a.__mul__(b)returnsNotImplementedtype(b)is a subclass oftype(a)— Python enforces this precedence rule so subclass overrides always win
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __mul__(self, other):
if isinstance(other, (int, float)):
return Vector(self.x * other, self.y * other)
return NotImplemented
def __rmul__(self, other):
# Delegating to __mul__ makes scalar multiplication commutative
return self.__mul__(other)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v = Vector(3, 4)
result = 2 * v # v.__mul__(2) → NotImplemented → Vector.__rmul__(v, 2)
print(result)
# output: Vector(6, 8)
When both sides of * are custom types, the full dispatch chain matters:
class Scalar:
def __mul__(self, other):
if isinstance(other, Vector):
return Vector(other.x * 2, other.y * 2)
return NotImplemented
class Vector:
def __mul__(self, other):
if isinstance(other, Scalar):
return Vector(self.x * 2, self.y * 2)
return NotImplemented
# No __rmul__ defined on Vector
s = Scalar()
v = Vector(3, 4)
print(s * v) # Scalar.__mul__(s, v) → Vector(6, 8)
print(v * s) # Vector.__mul__(v, s) → Vector(6, 8)
print(v * 2) # Vector.__mul__(v, 2) → NotImplemented → TypeError
If Vector.__rmul__ were also defined, it would handle v * 2 instead of raising TypeError.
__imul__ — in-place multiplication
__imul__ fires for the augmented assignment statement a *= b. Python evaluates it as type(a).__imul__(a, b).
The critical distinction from __mul__: for mutable objects, __imul__ should modify self in-place and return self. For immutable objects, it behaves identically to __mul__ — returning a new object is correct because rebinding the variable to a new object is the only possible outcome.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __imul__(self, other):
if isinstance(other, (int, float)):
self.x *= other
self.y *= other
return self
return NotImplemented
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v = Vector(3, 4)
v *= 2
print(v)
# output: Vector(6, 8)
__imul__ vs __mul__ on mutable objects
For immutable types (int, float, str, tuple) the two are functionally identical — a *= b behaves the same as a = a * b because a gets rebound to the new object regardless. For mutable objects, the difference is significant.
# list — __mul__ creates new, __imul__ extends in-place
lst = [1, 2, 3]
extended = lst * 2
print(extended) # [1, 2, 3, 1, 2, 3] — new list
print(lst) # [1, 2, 3] — original unchanged
lst = [1, 2, 3]
lst *= 2
print(lst) # [1, 2, 3, 1, 2, 3] — same object, mutated
With NumPy arrays the distinction also matters:
import numpy as np
a = np.array([1, 2, 3])
b = a * 2 # __mul__ — new array
a *= 2 # __imul__ — mutates a in-place
print(b) # [1 2 3]
print(a) # [2 4 6]
If __imul__ is not defined on a class, Python falls back to __mul__ for augmented assignment. If neither is defined, TypeError is raised.
Summary table
__mul__ | __rmul__ | __imul__ | |
|---|---|---|---|
| Trigger expression | a * b | a * b (reflected) | a *= b |
| Called when | a is an instance with __mul__ defined | a.__mul__(b) returns NotImplemented, or type(b) is a subclass of type(a) | Always for a *= b |
| Called as | type(a).__mul__(a, b) | type(b).__rmul__(b, a) | type(a).__imul__(a, b) |
| Return requirement | Result or NotImplemented | Result or NotImplemented | Return self (mutable) or new object (immutable) |
NotImplemented vs NotImplementedError | Return NotImplemented (singleton) to trigger fallback | Return NotImplemented to trigger further fallback | Raise NotImplementedError only if truly unimplemented |
| Mutable types | Returns new object | Same as __mul__ | Modifies self in-place |
| Immutable types | Returns new object | Same as __mul__ | Same as __mul__ |
See Also
- /reference/dunder-methods/add____radd____iadd/ — the same concepts apply to
__add__and__radd__for addition - /reference/dunder-methods/dunder-del/ — the deletion dunder pair,
__delitem__and__delattr__, follows a similar reflected-operator dispatch pattern