__mul__ / __rmul__ / __imul__

Updated March 27, 2026 · Dunder Methods
python __mul__ __rmul__ __imul__ dunder-methods

What triggers each method

Python calls these dunder methods at specific moments during evaluation of * expressions:

ExpressionMethod calledNotes
a * btype(a).__mul__(a, b)First attempt
a * btype(b).__rmul__(b, a)Reflected fallback if __mul__ returns NotImplemented
a *= btype(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__:

  1. a.__mul__(b) returns NotImplemented
  2. type(b) is a subclass of type(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 expressiona * ba * b (reflected)a *= b
Called whena is an instance with __mul__ defineda.__mul__(b) returns NotImplemented, or type(b) is a subclass of type(a)Always for a *= b
Called astype(a).__mul__(a, b)type(b).__rmul__(b, a)type(a).__imul__(a, b)
Return requirementResult or NotImplementedResult or NotImplementedReturn self (mutable) or new object (immutable)
NotImplemented vs NotImplementedErrorReturn NotImplemented (singleton) to trigger fallbackReturn NotImplemented to trigger further fallbackRaise NotImplementedError only if truly unimplemented
Mutable typesReturns new objectSame as __mul__Modifies self in-place
Immutable typesReturns new objectSame as __mul__Same as __mul__

See Also