Describing the operation instead of writing the loop - and the two habits that undo the benefit.
Overview
Operators apply to every element
The defining feature of NumPy arrays is that arithmetic operators work elementwise. Adding two arrays adds corresponding elements. Multiplying by a scalar multiplies every element. There is no loop in Python. The operation happens in compiled code, and the result is a new array of the same shape.
That elementwise behaviour is what makes NumPy fast. The same computation written as a Python loop would be a hundred times slower, because each iteration would need to look up the type of each element, call the operator, and build a new Python object for the result. The NumPy version does none of that. It knows the type once and loops in C.
Every standard arithmetic operator works this way. Addition, subtraction, multiplication, division, integer division, modulus, and exponentiation all apply elementwise. So do comparison operators. a > 5 gives a boolean array with True where the condition holds and False where it does not.
Scalar operands are broadcast automatically. a + 10 adds ten to every element. a * 0.5 halves every element. The scalar is not expanded into a full array. NumPy reuses the value at each step of the compiled loop. That is both faster and more memory-efficient than creating a temporary array of tens.
The one operator that surprises people is the asterisk. On arrays, a * b is elementwise multiplication. It is not matrix multiplication. Matrix multiplication uses the @ operator, or np.dot, or np.matmul. Confusing the two gives an array of the same shape but entirely wrong values, with no error or warning.
This module covers the arithmetic operators, the comparison operators, and the difference between elementwise and matrix multiplication. It also covers the NumPy functions like np.sqrt and np.exp that apply elementwise and replace the math-module equivalents for array inputs.
a + b adds the arrays elementwise. So do -, *, /, //, % and **. A scalar applies to each element: a * 10 scales the whole array.
The one to be careful with is *. On arrays it's elementwise product, not mat product. mat product is @. Confusing the two makes an array of the wrong shape or, worse, the right shape and the wrong numbers. That has its own module later.
checks work the same way and give a boolean array: a > 2 is [False, False, True, True], not a single answer. Using that in an if raises. NumPy refuses to guess whether you meant "any" or "all".
Worth knowing
Every arithmetic operator works elementwise, including with a scalar. * is elementwise multiplication, not a dot product.
ufuncs like np.sqrt apply to a whole array; math.sqrt takes one number and raises on an array.
Float division by zero gives inf or nan with a warning rather than an exception. Python would have raised.
a += 1 writes in place; a = a + 1 allocates a new array and rebinds the name. Aliases see one and not the other.
Indexing an array element-by-element from Python is slower than the same loop over a list, because each value is boxed into an object.
The usual conversion is: compute the branches over the whole array, then combine them with np.where.
Vectorised Arithmetic
showing the step instead of writing the loop. The two habits that undo the gain.
Every operator works elementwise
The arithmetic you already know, applied to whole arrays at once.
example_01.pyNumPy
import numpy as np
a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])
print("a + b :", a + b)
print("b - a :", b - a)
print("a * b :", a * b, "<- elementwise, NOT a dot product")
print("b / a :", b / a, "<- always float")
print("b // a:", b // a)
print("b % a :", b % a)
print("a ** 2:", a ** 2)
print()
print("and with a scalar, which applies to every element:")
print("a * 10:", a * 10)
print("a > 2 :", a > 2, "<- comparisons give a boolean array")
Output
ufuncs: the same idea for functions
np.sqrt, np.exp and the rest apply elementwise too. Python's math module does not.
example_02.pyNumPy
import numpy as np
import math
a = np.array([1.0, 4.0, 9.0, 16.0])
print("np.sqrt :", np.sqrt(a))
print("np.exp :", np.exp(np.array([0.0, 1.0])))
print("np.log :", np.log(np.array([1.0, math.e])))
print("np.sin :", np.round(np.sin(np.array([0.0, math.pi / 2])), 6))
print()
try:
math.sqrt(a)
except TypeError as e:
print("math.sqrt on an array:", type(e).__name__)
print(" ", e)
print(" math works on one number; np works on the whole array.")
Output
Division by zero warns, it does not raise
Floating point has infinities and NaN, so NumPy produces them and carries on. That is a decision you should know about.
example_03.pyNumPy
import numpy as np
a = np.array([1.0, 0.0, -1.0])
with np.errstate(divide="ignore", invalid="ignore"):
r = a / 0.0
print("a / 0 :", r, "<- inf, nan, -inf")
print()
print("0/0 is nan, x/0 is signed infinity, and nothing raised.")
print()
print("Integer division by zero is different:")
i = np.array([1, 0], dtype=np.int64)
with np.errstate(divide="ignore"):
print(" int a // 0:", i // 0, "<- zero, with a RuntimeWarning")
print()
print("Python would have raised ZeroDivisionError for both.")
Output
In-place operations avoid a temporary
a += 1 writes into the existing array; a = a + 1 builds a new one. On large arrays that is the difference.
example_04.pyNumPy
import numpy as np
a = np.arange(5)
before = a
a += 10 # in place: same array
print("a += 10 :", a, "| same object:", a is before)
b = np.arange(5)
before_b = b
b = b + 10 # new array, name rebound
print("b = b+10:", b, "| same object:", b is before_b)
print()
print("The difference matters when something else holds a reference:")
shared = np.zeros(3)
alias = shared
print(" start shared:", shared, " alias:", alias)
shared += 1 # writes into the buffer both names see
print(" after += shared:", shared, " alias:", alias, "<- followed")
shared = shared + 1 # rebinds shared; alias still points at the old
print(" after = + shared:", shared, " alias:", alias, "<- left behind")
Output
Where vectorisation is lost
Two habits give the memory cost of arrays with the speed of lists.
example_05.pyNumPy
import numpy as np
import time
n = 60_000
a = np.arange(n, dtype=np.float64)
t = time.perf_counter()
out = np.empty(n)
for i in range(n):
out[i] = a[i] * 2 + 1 # element access from Python
loop = time.perf_counter() - t
t = time.perf_counter()
out2 = a * 2 + 1 # one expression
vec = time.perf_counter() - t
print("python loop over an array : %.4f s" % loop)
print("one vectorised expression : %.4f s" % vec)
print("ratio : %.0fx" % (loop / vec))
print("same result :", np.array_equal(out, out2))
print()
print("Indexing an array from Python boxes each value into an object.")
print("That is slower than the same loop over a list.")
Output
A worked replacement
Turning a loop with a condition into array expressions — the shape most real conversions take.
example_06.pyNumPy
import numpy as np
temps = np.array([12.0, 18.5, 24.0, 31.2, 8.4, 27.9])
# The loop version.
out = []
for t in temps:
f = t * 9 / 5 + 32
out.append(f if t > 15 else 0.0)
loop_result = np.array(out)
# The array version: compute both, then choose.
fahrenheit = temps * 9 / 5 + 32
vec_result = np.where(temps > 15, fahrenheit, 0.0)
print("temps :", temps)
print("loop :", loop_result)
print("arrays :", vec_result)
print("equal :", np.allclose(loop_result, vec_result))
print()
print("np.where picks elementwise between two arrays using a mask -")
print("the vectorised form of if/else, and the subject of a later module.")
Output
ufuncs
The math functions come in array form: np.sqrt, np.exp, np.log, np.sin, np.abs. These are called universal functions. They apply elementwise to a whole array in compiled code.
Python's math module doesn't. math.sqrt takes one number and raises on an array. Reaching for math inside a loop over an array is one of the two common ways to lose all NumPy offers.
Division by zero does not raise
This surprises people coming from plain Python.
Float division by zero makes inf, -inf or nan and emits a RuntimeWarning. It doesn't stop. That follows the IEEE floating-point standard. It's the right default for array work. One bad element in a million shouldn't abort the whole math.
The result is that you must look for those values later rather than leaning on an exception. np.isnan and np.isfinite are how. There's a module on missing data later.
np.errstate controls the warnings when you're making them on purpose. This Is what the third editor uses to keep its output readable.
In place or not
a += 1 modifies the existing array. a = a + 1 builds a new array and rebinds the name.
Two reasons to care.
Memory. The second form allocates a full-size temporary. In a chain like a = a * 2 + b * 3, a few temporaries exist at once. This Matters when arrays are large.
Aliasing. If a thing else holds a link to the same array. Another var, a list, a caller's data. The in-place form changes what they see and the rebinding form doesn't. The fourth editor shows an alias tracking one and being left behind by the other.
Neither is right . In-place is cheaper and can surprise. Allocation is safer and costs memory. What matters is knowing which one you wrote.
The two ways to lose the benefit
Looping over an array in Python. This is the big one. Reading a[i] from Python boxes that value into a Python object. Doing that in a loop is slower than the same loop over a list. You get the memory layout of an array and none of the speed. The fifth editor measures it.
Calling scalar functions per element.math.sqrt(a[i]) in a loop, or a Python function applied one value at a time. np.vectorize looks like a fix and isn't. It's a convenience wrapper that still loops in Python. Its own docs says so.
If you find yourself indexing an array inside a for, the handy question is what whole-array formula would give the same result.
Converting a loop
The common shape is a loop with a rule, and it converts in two steps.
Find the branches for the whole array, then combine them with a mask. np.where(rule, if_true, if_false) chooses elementwise.
That does more math than the loop. It tests both branches all over. And is still sharply faster. The math happens in compiled code and the loop doesn't.
It also reads better once you're used to it. The rule and the two outcomes are each one formula, rather than being spread across a loop body.
Where both branches are costly, or one is invalid for some inputs, masking gets more careful. And that is the boolean-masking module.
The ufunc machinery
A ufunc isn't just a function that happens to work elementwise. It's an object with methods. Three of them are worth knowing.
np.add.reduce(a) is a.sum(). each binary ufunc has a reduce. np.times.reduce gives a product, np.max.reduce gives a max, and np.logical_and.reduce gives an all.
np.add.build up(a) is a.cumsum(). The same generalisation: a running use of the step.
np.add.outer(a, b) applies the step to each pair, giving a mat from two vectors. np.times.outer is the outer product. np.subtract.outer gives a gap mat. This Is a one-line distance math.
These matter because they cover steps with no dedicated function. There's no np.cummax. np.max.build up is just that. It's the standard way to find a running high-water mark.
Ufuncs also accept where=. This Applies the step only where a mask is True and leaves the rest of the output untouched. Mixed with out=, that is gated math without a branch or an mid array.
Comparison and logic are ufuncs too
a > b is np.greater(a, b). a == b is np.equal. They broadcast, they take out=, they've reduce.
That last one is handy: np.logical_or.reduce(masks) mixes a list of masks. This Is cleaner than chaining | when the number of rules isn't known in advance.
The bitwise ops &, | and ~ are the array versions of and, or and not. The Python keywords don't work on arrays at all. They raise. Python needs a single truth value and an array has many. That error message, "the truth value of an array with more than one element is ambiguous", is one of the most often seen in NumPy. It always means the same thing: replace and with &, and add parentheses.
Controlling floating-point warnings
np.errstate is a context manager that decides what happens on division by zero, overflow, underflow and invalid steps.
The options are "warn" (the default for most), "skip", "raise" and "call".
Two settings are worth knowing about. np.errstate(all="raise") turns silent NaN production into an exception. This Is invaluable when hunting a NaN that appears one spot in a long pipeline. It stops at the step that created it rather than at the point where you noticed.
np.errstate(divide="skip", invalid="skip") suppresses the warnings when the action is intentional. This Keeps genuine warnings visible instead of drowning in expected ones.
np.seterr sets the same options globally. This Is convenient in a notebook and inadvisable in a library. It changes action for all else's code too.
When a loop is still the right answer
Vectorisation is the default, not a rule.
When each step depends on the last. Models, iterative solvers, anything where element n needs the found value of element n-1. Some of these have array formulations. A cumulative sum, an steep moving mean via lfilter. But many truly don't.
When the array is small. Under a few hundred elements, the per-call cost of NumPy dominates and a list comprehension can be faster. Measure before given.
When the vectorised version is unreadable. A three-line loop that a colleague understands is often better than a one-line formula with four newaxis insertions that no one can modify safely. This is a real engineering trade-off, not a failure of nerve.
When it would allocate too much. Some vectorised formulations build a large mid. The classic being an n-by-n distance mat for large n. A loop over chunks can be the only version that fits in memory.
The honest framing: reach for vectorisation first. It's often shorter and much faster. Fall back to a loop when the problem is sequential, the data is small, or the vectorised form costs more in clarity or memory than it returns in speed.
np.vectorize is not vectorisation
The name is misleading enough to be worth a warning.
np.vectorize(f) wraps a scalar function so it accepts arrays. It handles broadcasting and dtype for you, and it's truly convenient.
It's a loop underneath. The docs says so. It gives the interface of a ufunc without the speed. Code that uses it expecting a speed gain gets none.
Its real use is convenience: applying an existing scalar function to an array without rewriting it. Here The array is small enough that speed doesn't matter. For anything in a hot path, the function needs to be rewritten by array steps, or compiled with something like Numba.
Integer division and the modulo operator
// and % work elementwise like all else, and both have an edge worth knowing.
int division by zero doesn't give infinity. Ints have no infinity. It makes 0 and a warning. This Is arguably worse than a NaN because it looks like a legitimate answer.
% follows Python's sign rule rather than C's. The result takes the sign of the divisor. -7 % 3 is 2, not -1. That matches plain Python and differs from many other languages. This Matters when porting an method.
np.divmod returns both at once and is faster than finding them apart.
Power, and the integer surprise
a ** 2 on an int array stays int. Can overflow just as product does.
a ** -1 on an int array raises. A minus power of an int isn't an int. This is a deliberate change from older NumPy. This Returned nonsense. Convert to float first.
np.sqrt of a minus float gives NaN with a warning, not a complex number. If complex results are wanted, the input must already be complex: np.sqrt(np.array([-1+0j])) gives 1j. NumPy won't silently promote a real array to complex. Doing so would change the dtype of all downstream.
Rounding, and the rule that surprises people
np.round uses banker's rounding: exact halves round to the nearest even number. np.round(0.5) is 0 and np.round(1.5) is 2.
This is deliberate. Always rounding halves upward adds a systematic upward bias when blending many rounded values. Rounding to even cancels it out.
It's also not what most people expect. It's worth knowing before writing a test that asserts round(0.5) == 1.
np.floor, np.ceil and np.trunc have no such subtlety. Note that trunc and floor differ for negatives: floor(-1.5) is -2, trunc(-1.5) is -1.
Converting to int with astype(int) truncates toward zero. This Is trunc, not round. That gap makes off-by-one results in just the places that are hardest to notice.
Questions people ask about Vectorised Arithmetic
What does vectorised mean? The operation applies to the whole array in one call, with the loop done in compiled code. You write a + b instead of looping over elements.
What is a ufunc? A universal function, such as np.add or np.exp, that operates element by element and supports broadcasting, out= and dtype arguments.
Why is x / 0 not an error? NumPy follows IEEE floating point and returns inf or nan with a warning. Use np.errstate to control or raise on those cases.
How do I apply a custom Python function? First try to express it with ufuncs and np.where. np.vectorize only hides the loop and is no faster.
What does the out argument do? It writes the result into an existing array instead of allocating a new one. That saves memory and time in repeated calculations.
Is a / b the same as a // b? No. / always gives floats, while // is floor division and keeps integer types for integer inputs. Mind the rounding on negative numbers.
A checklist for converting a loop
When replacing a Python loop with array steps, the questions in order:
Does each loop depend on the previous one? If yes, it may not vectorise. Check whether it's a cumulative step in disguise. cumsum, cumprod, max.build up cover more cases than people expect.
Is there a gated inside?np.where for two branches, np.select for more, or a boolean mask if the step applies to a subset.
Is it building a list? Preallocate the output array and assign into it, or express the whole thing as one formula.
Is it mixing each pair? That is broadcasting with a None insertion. It's worth checking the resulting size before running it.
Is it a reduction?sum, max, any with the right axis, rather than building up in a Python var.
Most loops in numeric code fall into one of those five. The ones that don't are often truly sequential. Are the right place for a loop. Or for Numba, if the loop is the bottleneck.
Check yourself
0 of 4
Answer without scrolling back up.
What does `*` do between two arrays?
Matrix multiplication is `@`. Confusing them gives either a shape error or, worse, a plausible shape with wrong numbers.
What does float division by zero produce in NumPy?
IEEE semantics: one bad element should not abort a whole computation. You check afterwards with isnan/isfinite rather than catching an exception.
How does `a += 1` differ from `a = a + 1`?
It matters for memory - the second allocates a temporary - and for aliasing: another reference to the array sees the in-place change but not the rebinding.
Why is looping over an array in Python slower than looping over a list?
You get the memory layout of an array and none of the speed. `np.vectorize` does not fix this - it still loops in Python.
Cheat sheet
Vectorised Arithmetic
Replace Python loops with NumPy element-wise operations. See the speedup on arrays of every size and avoid the two patterns that lose it.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.