2025, Sep 27 07:00
How to Make Custom Python Numbers Work in NumPy Arrays with dtype=object and Dunder Methods
Learn how NumPy dtype=object dispatches to your class's dunder methods. Implement __add__, __mul__, and reverse ops to support mixed arrays, ints, and chains.
Building custom numeric behavior in Python often starts with dunder methods like __add__ and __mul__. The catch appears when those objects are placed inside a NumPy ndarray and used in arithmetic with arrays and scalars. Do ndarrays still respect your class’s operators? Can you mix custom objects with Python ints? The short answer, demonstrated below, is that with dtype=object NumPy delegates element-wise to your class methods, and with a couple of small additions you can make mixed and chained operations behave consistently.
Problem setup
The goal is to use a custom class that overrides + and * and have those operators work correctly both for scalar-scalar operations and when instances are stored in a regular ndarray. The desired behavior includes operations between arrays of custom objects, between arrays and single custom objects, between arrays of custom objects and arrays of Python ints, and also reversed operations such as int * custom_object. Finally, chained expressions should preserve the custom semantics end-to-end.
Minimal example that surfaces the behavior
The following class flips the meaning of + and * purely to make the results unmistakable in demos. The array is explicitly created with dtype=object so elements remain Python objects.
import numpy as np
class Quark:
    def __init__(self, payload):
        self.payload = payload
    def __add__(self, other):
        return self.payload * other.payload
    def __mul__(self, other):
        return self.payload + other.payload
x = Quark(5)
y = Quark(3)
box = np.array([x, y], dtype=object)
res = box * Quark(10)
print(res)  # [15 13], as expected
res = box * np.array([y, x], dtype=object)
print(res)  # [8 8], also as expected
When both operands are arrays of custom objects, or when an array of custom objects is combined with a single custom object, the per-element result uses the class’s dunder methods. That is the key behavior: element-wise dispatch calls the custom operator for each object stored in the array.
What’s actually going on
With dtype=object, arithmetic involving ndarrays applies the operation element by element using Python’s normal operator resolution rules. In practice, that means each element’s __add__ or __mul__ is invoked, and in mixed-type scenarios the reverse methods like __radd__ and __rmul__ come into play if the left-hand operand’s method does not handle the operation. This matches what you can observe by running the examples and what is confirmed by the outcomes below.
Handling mixed operations with Python ints
If you mix an array of custom objects with an array of ints, a direct call may raise a ValueError, because the custom method expects a custom instance. You can make the class robust by accepting both your custom type and plain numbers.
class Quark:
    def __init__(self, payload):
        self.payload = payload
    def __add__(self, other):
        if isinstance(other, Quark):
            return self.payload * other.payload
        return self.payload * other
    def __mul__(self, other):
        if isinstance(other, Quark):
            return self.payload + other.payload
        return self.payload + other
With this change, combining an object array with an int array is fine.
res = box * np.array([1, 2], dtype=object)  # [6 5]
However, reversing the operands can still fail because the int’s operator executes first and does not know how to combine with the custom type. This is where reverse dunder methods matter.
res = np.array([1, 2], dtype=object) * box  # error without reverse methods
Implementing __radd__ and __rmul__ delegates back to the same logic and resolves the asymmetry.
class Quark:
    def __init__(self, payload):
        self.payload = payload
    def __add__(self, other):
        if isinstance(other, Quark):
            return self.payload * other.payload
        return self.payload * other
    def __mul__(self, other):
        if isinstance(other, Quark):
            return self.payload + other.payload
        return self.payload + other
    def __radd__(self, other):
        return self.__add__(other)
    def __rmul__(self, other):
        return self.__mul__(other)
Now both array * array and array * scalar work regardless of order.
res = np.array([1, 2], dtype=object) * box  # [6 5]
The same symmetry enables mixed object/number arrays to combine cleanly.
u = np.array([x, 2], dtype=object)
v = np.array([10, y], dtype=object)
out = u + v  # [50 6]
Preserving custom behavior in chained expressions
One more subtlety shows up in chained operations. If your operators return plain Python numbers, the next step of the chain will use the default numeric semantics, not your custom semantics. Consider the following expression where the left-hand operation returns an int:
res = 2 * Quark(3) * 5  # 25, but the intended result under swapped semantics is 10
The first part, 2 * Quark(3), yields 5 under the swapped rules, but then 5 * 5 becomes a normal int multiplication and returns 25. The fix is to return your own type from the operators so the chain continues to dispatch to the custom methods.
class Quark:
    def __init__(self, payload):
        self.payload = payload
    def __add__(self, other):
        if isinstance(other, Quark):
            return Quark(self.payload * other.payload)
        return Quark(self.payload * other)
    def __mul__(self, other):
        if isinstance(other, Quark):
            return Quark(self.payload + other.payload)
        return Quark(self.payload + other)
    def __radd__(self, other):
        return self.__add__(other)
    def __rmul__(self, other):
        return self.__mul__(other)
With this change, the chain stays within the custom type and keeps the intended operator semantics.
res = 2 * Quark(3) * 5
print(res.payload)  # 10
For nicer displays in interactive output, you can expose the underlying value via a string representation.
def __repr__(self):
    return str(self.payload)
After that, printing the object shows the payload directly.
res = 2 * Quark(3) * 5  # 10
Solution summary and working model
The key is that object arrays perform operations element-wise and call Python’s operator methods of the stored objects. By providing __add__, __mul__, __radd__, and __rmul__ that accept both your custom type and plain numbers, and by returning your custom type from those operators, you get consistent behavior across scalar, array, mixed-type, reversed, and chained operations. This holds for standard ndarrays, and even wrapping or re-wrapping with np.asarray preserves the behavior when dtype=object is maintained.
Why this matters
Understanding this dispatch model lets you design custom scalar-like classes that integrate with NumPy without replacing the array container or relying on array-wide hooks. That can be valuable when array creation or usage patterns are not under your control, and elements may appear in different contexts: individual scalars, arrays, or mixed with Python numbers. With the right set of dunder methods, the same semantics apply consistently and predictably.
Takeaways
If you need custom arithmetic inside NumPy ndarrays, store your objects in dtype=object arrays so each operation invokes the object’s own methods. Implement both the forward and reverse operators to cover mixed and reversed operand orders. When you want chained expressions to retain the same semantics, return your own type from arithmetic methods rather than a bare Python primitive. If you prefer clean interactive output, provide a __repr__ that surfaces the underlying value. With these pieces in place, standard ndarrays are enough to get the behavior you want.