PYTHON / NUMPY
Vectorized arithmetic and ufuncs
Use NumPy ufuncs to do element-wise math on whole arrays, control result dtype and memory with out= and where=, and predict inf/nan and overflow.
What you will learn
- Know that a + b dispatches to np.add and shares its dtype and casting rules
- Use out= and in-place operators to avoid allocating temporary arrays
- Predict result dtype from input dtypes: int8+int8 wraps, / always yields float
- Handle divide-by-zero with np.errstate or skip elements with where=
Understanding Vectorized arithmetic and ufuncs
Every arithmetic operator on an array is a thin wrapper around a ufunc: `a + b` calls `np.add(a, b)`, `a / b` calls `np.true_divide`, `-a` calls `np.negative`. A ufunc is a small object holding a table of compiled inner loops, one per dtype combination (inspect it with `np.add.types`), plus metadata like `nin`, `nout`, and `identity`. When you call it, NumPy resolves which loop matches the input dtypes, allocates a result buffer, and runs one C loop over the raw memory. That is why the speedup over a Python loop is real: the interpreter never sees the individual elements, so you pay no per-element attribute lookup, boxing, or reference counting.
Because each ufunc call allocates a fresh result, an expression like `(a * b + c) / d` builds three temporary arrays before you get the answer. For large arrays the memory traffic, not the arithmetic, dominates the runtime. Two escape hatches exist: pass `out=` to write into a buffer you already own, and use in-place operators (`a *= 3`), which are compiled to `np.multiply(a, 3, out=a)`. In-place means the destination dtype is fixed, so the computed result must be castable back under `same_kind` rules — and because it writes through the same memory, any view of that array changes too.
The result dtype comes from the *input dtypes*, never from the values, since the loop is chosen before a single element is read. So `int8 + int8` stays `int8` and 140 silently wraps to -116, while `int // int` stays integer and `int / int` jumps to float64 because `np.true_divide` has no integer loop at all. Floating-point trouble is reported through the error state rather than exceptions: `4.0 / 0.0` gives `inf` with a RuntimeWarning, `0.0 / 0.0` gives `nan`, `np.sqrt(-1.0)` gives `nan`. Use `np.errstate` to choose warn/ignore/raise, or `where=` together with a preinitialized `out` to skip the bad elements entirely.
import numpy as np
a = np.array([1.0, 2.0, 3.0, 4.0])
b = np.array([10.0, 20.0, 30.0, 40.0])
print(a + b)
print(np.add(a, b))
print(np.add.nin, np.add.nout, np.add.identity)
# out= reuses one buffer instead of allocating a temporary per step
scratch = np.empty_like(a)
np.multiply(a, b, out=scratch)
np.sqrt(scratch, out=scratch)
print(scratch)
c = a.copy()
c *= 3 # in-place: no new array, dtype unchanged
print(c, c.dtype)Array operators are calls to ufuncs: compiled per-dtype element loops whose result dtype is decided by the input dtypes, not by the values.
Worked examples
Result dtype and silent integer overflow
Shows that the ufunc picks its loop from the input dtypes, so integers wrap and true division always leaves the integer world.
import numpy as np
x = np.array([100, 120], dtype=np.int8)
y = np.array([100, 10], dtype=np.int8)
print(x + y, (x + y).dtype)
z = x.astype(np.int16) + y
print(z, z.dtype)
ints = np.array([7, 8, 9])
print(ints / 2, (ints / 2).dtype)
print(ints // 2, (ints // 2).dtype)Example explained
Line 1int8 + int8 selects the int8 inner loop, so 200 and 130 wrap into the signed 8-bit range with no warning.
Line 2Promoting one operand with astype(np.int16) makes NumPy pick the int16 loop, and the sums now fit.
Line 3`/` is np.true_divide, which has no integer loop, so both operands are cast to float64 first.
Line 4`//` is np.floor_divide, which does have an integer loop, so the dtype stays int64.
Dividing safely with where= and errstate
Two ways to deal with zeros in a denominator: skip those elements, or accept inf and silence the warning.
import numpy as np
num = np.array([4.0, 9.0, 1.0, 5.0])
den = np.array([2.0, 0.0, 0.0, 5.0])
result = np.zeros_like(num)
np.divide(num, den, out=result, where=den != 0)
print(result)
with np.errstate(divide='ignore'):
naive = num / den
print(naive)Example explained
Line 1`where=den != 0` tells the inner loop to skip those positions, so nothing is computed and no flag is raised.
Line 2Because `out=result` was zeroed first, the skipped positions read back as 0.0 instead of garbage.
Line 3Without where, division by zero sets the 'divide' floating-point flag; NumPy stores inf and normally prints a RuntimeWarning.
Line 4np.errstate only changes how that flag is reported; the stored values are inf either way.
Ufunc methods: accumulate, outer, and at
Demonstrates that a ufunc is an object with methods, including the unbuffered np.add.at that fixes duplicate-index updates.
import numpy as np
v = np.array([1, 2, 3, 4])
print(np.add.accumulate(v))
print(np.multiply.outer(v, np.array([1, 10])))
idx = np.array([0, 2, 2, 3, 2])
counts = np.zeros(4, dtype=int)
counts[idx] += 1
print(counts)
counts2 = np.zeros(4, dtype=int)
np.add.at(counts2, idx, 1)
print(counts2)Example explained
Line 1accumulate applies np.add pairwise along the axis and keeps every partial result, so the output length matches the input.
Line 2outer runs the multiply loop over every pair of elements, producing shape (4, 2) with no Python loop.
Line 3`counts[idx] += 1` is fetch, add, scatter: index 2 is written once, so its three occurrences collapse to 1.
Line 4np.add.at applies the loop unbuffered, element by element, so index 2 accumulates all three increments.
Important notes
An out= array must match the broadcast shape exactly and accept the computed dtype under same_kind casting; np.add(f, f, out=int_arr) raises instead of truncating.
Comparison and bitwise operators are ufuncs too (np.less, np.bitwise_and), so out= and where= work there; Python's `and`/`or` are not ufuncs and raise on arrays with more than one element.
Common mistakes
Assuming NumPy raises on integer overflow: int8/int16/int32 arithmetic wraps silently, so a large sum can come out negative and no warning appears.
Calling np.divide(a, b, where=mask) without out=: the skipped positions come from an uninitialized np.empty buffer, so you get random leftover values.
Writing `arr /= 2` on an integer array: it raises UFuncTypeError because a float64 result cannot be cast back into int under same_kind — use `arr = arr / 2` or `arr //= 2`.
Try it yourself
Change, predict, then run
Given `temps_c = np.array([-40, 0, 37, 100], dtype=np.int16)`, compute Fahrenheit as a single vectorized expression and print the result with its dtype; then repeat the calculation writing into a preallocated `np.empty(4, dtype=np.float64)` using np.multiply and np.add with out=, and confirm both arrays are equal.
Open the Python workspaceCheck your understanding
With `x = np.array([60, 70], dtype=np.int8)`, what does `print(x + x)` produce?
- [ 120 -116] — the int8 loop is used and 140 wraps around
- [120 140] — NumPy promotes to int16 because 140 does not fit in int8
- OverflowError, since the addition cannot be represented in int8
- [120 127] — values are clipped at the int8 maximum
Show answer
np.add resolves its inner loop from the input dtypes before reading any values, so int8 + int8 runs the int8 loop and 140 wraps to -116 with no check. Promotion to int16 would require inspecting the data, which the ufunc never does, and wraparound is modular arithmetic, not clipping.