Universal Functions (ufuncs)
A universal function - a ufunc - is a single operation applied elementwise over arrays, implemented as a tight C loop with the full feature set of the API: multiple dtypes, in-place writes into a named output, and per-operation error control. The arithmetic operators (+, *) are ufuncs in disguise, and the library's hundreds of elementwise operations - sqrt, log, sin, clip, where - all follow the same calling convention. Learning the ufunc interface once makes every one of them usable at full speed.
What a ufunc Is
import numpy as np
a = np.array([1.0, 4.0, 9.0])
print(np.sqrt(a)) # [1. 2. 3.] - the operation, elementwise
print(np.add(1, 2)) # 3 - ufuncs accept scalars too
print(np.add.types) # the supported type signatures ('dd->d', ...)
# every operator is a ufunc call in disguise
x = np.array([1, 2, 3])
print(x + 1) # np.add(x, 1)
print(x * x) # np.multiply(x, x)
print(-x) # np.negative(x)
Each ufunc exposes its supported signatures (np.add.types), and each signature is a compiled loop for a specific dtype combination - that is the mechanism behind dtype dispatch: calling np.sqrt on a float32 array runs the float32 loop, with no Python-side type checking per element. The common family is large: math (sqrt, exp, log, log2, log10, pow), trig (sin, cos, tan, and the hyperbolic set), rounding (floor, ceil, round, trunc), comparisons (equal, greater, ...), and the two-argument selection set (maximum, minimum, clip, where).
out and where: The Full Calling Convention
a = np.array([1.0, -2.0, 3.0, -4.0])
b = np.array([10.0, 20.0, 30.0, 40.0])
# named output buffer - zero allocation
out = np.empty(4)
np.add(a, b, out=out)
print(out)
# where: apply only where the mask is True, leave the rest untouched
base = np.zeros(4)
np.add(a, b, out=base, where=(a > 0))
print(base) # [11. 0. 33. 0.]
# np.where: the ternary form - pick elementwise between two arrays
print(np.where(a > 0, a, 0)) # [1. 0. 3. 0.]
print(np.where(a > 0, "pos", "neg"))
The keyword interface is where ufuncs earn their keep. out= names the destination (no temporary is allocated - the hot-loop form). where=mask restricts the operation to the True positions of a boolean mask, leaving other elements of the output untouched - conditional computation without a branch per element. And np.where(cond, x, y) is the elementwise ternary: the vectorized replacement for [xi if c else yi for ...] that scales to millions of elements.
reduce and accumulate
a = np.array([1, 2, 3, 4, 5])
print(np.add.reduce(a)) # 15 - the same as a.sum()
print(np.multiply.reduce(a)) # 120 - the same as a.prod()
print(np.maximum.reduce(a)) # 5 - the same as a.max()
print(np.add.accumulate(a)) # [ 1 3 6 10 15] - running sums
print(np.multiply.accumulate(a)) # [ 1 2 6 24 120] - running products
# custom: reduce with a different binary function
import operator
print(np.frompyfunc(operator.truediv, 2, 1).reduce(a)) # less common, but the pattern
Every ufunc is a binary operation, and two of its derived forms are general: reduce folds the operation across an axis (that is what sum, prod, max are), and accumulate returns the running result - the vectorized prefix sum, the standard tool for cumulative totals, running averages, and scan-style computations. If you find yourself writing a loop that carries a running state, check whether a ufunc's accumulate is the operation.
Controlling Floating-Point Warnings
a = np.array([1.0, 0.0, -1.0])
print(np.log(a))
# [0. -inf nan] (with a RuntimeWarning: divide by zero / invalid value)
# silence known, intended cases
with np.errstate(divide="ignore", invalid="ignore"):
safe = np.log(a)
print(safe)
# make a specific case a hard error
with np.errstate(all="raise"):
try:
np.sqrt(np.array([-1.0]))
except FloatingPointError:
print("raised")
np.errstate is a context manager that sets floating-point error behavior - ignore, warn, raise, or call - per condition (divide, over, under, invalid). The pattern: wrap computations where -inf or nan are expected and handled (log of zero before a mask) so the warning stream stays clean, and set all="raise" in tests to catch the cases that are not.
Where ufuncs Stop
- Ufuncs are elementwise (or reduce/accumulate along axes) - operations that need a neighbor (convolution, sorting) or a whole matrix (eigenvalues) are separate functions, not ufuncs.
np.atis the exotic exception: unbuffered elementwise accumulation for duplicate indices (np.add.at(b, [0, 0, 1], [1, 2, 3])adds both into index 0) - the fix for the "sum into buckets with repeated indices" problem.
Summary
Treat elementwise operations as ufunc calls: the operators are shorthand, and the full interface adds out= (zero allocation), where=mask (conditional application), and np.where (elementwise ternary). Use reduce/accumulate for the fold and running-state patterns, keep the warning stream honest with np.errstate, and reach for np.add.at when accumulation targets repeat. The ufunc convention is one calling style that spans hundreds of operations - learn it once, and the entire elementwise library is at full speed.