Vectorization & Performance
Write fast NumPy code: replace Python loops with ufuncs, np.where and broadcasting, avoid np.append in loops, preallocate, and use in-place operations.
NumPy is fast only if you let it be. If you write a Python for loop over a NumPy array, you are back to Python speed — sometimes slower than a plain list. The skill that separates beginner from professional NumPy code is vectorisation: expressing the work as whole-array operations.
Definition
Vectorisation means replacing explicit Python loops over elements with array expressions (ufuncs, broadcasting, boolean masks, aggregations) that NumPy executes in optimised, compiled C code.
- Ufunc (universal function) — a NumPy function that works element-by-element on whole arrays in C:
np.add,np.sqrt,np.exp, and the operators+ - * / ** > ==. - Why it is faster — a Python loop pays interpreter overhead (type checks, object creation) for every element; a ufunc pays it once per array and then loops in C over contiguous memory, often using SIMD CPU instructions.
- Typical speed-up — 10× to 100× or more.
The vectorisation toolbox
| Loop pattern | Vectorised replacement |
|---|---|
for i: out[i] = a[i] * b[i] | a * b (ufunc) |
for i: if a[i] > t: x else y | np.where(a > t, x, y) |
for i: if cond: count += 1 | np.sum(cond) / np.count_nonzero |
for i: if cond: keep.append(a[i]) | a[cond] (boolean mask) |
| running total loop | np.cumsum(a) |
| nested loops over rows and columns | broadcasting: a - a.mean(axis=1, keepdims=True) |
total = sum(arr) (Python sum) | arr.sum() / np.sum(arr) |
arr = np.append(arr, x) in a loop | preallocate np.empty(n), or build a list then np.array once |
Syntax
python
np.where(condition, x, y)
np.empty(shape, dtype=float) # allocate without initialising
np.zeros(shape, dtype=float) # allocate filled with 0
np.add(a, b, out=a) # write the result into an existing array
a += 1 # in-place operator| Function | Parameter | Meaning | Default |
|---|---|---|---|
np.where | condition, x, y | pick from x where True, y where False | — required |
np.empty | shape, dtype | new array of that shape, contents are garbage until filled | dtype=float |
| ufuncs | out | existing array to store the result (no new allocation) | None |
Example — loop vs vectorised strike rates
python
import numpy as np
runs = np.array([72, 45, 88, 31, 64])
balls = np.array([48, 40, 52, 30, 41])
# Loop version
sr_loop = []
for r, b in zip(runs, balls):
sr_loop.append(round(float(r / b * 100), 1))
print(sr_loop)
# Vectorised version
sr_vec = (runs / balls * 100).round(1)
print(sr_vec)text
[150.0, 112.5, 169.2, 103.3, 156.1]
[150. 112.5 169.2 103.3 156.1]Same numbers, but the vectorised line is shorter, clearer and — on a million players — about 100× faster.
Example — if/else inside a loop → np.where
python
scores = np.array([72, 8, 45, 0, 103, 52, 19])
labels = np.where(scores >= 100, "century",
np.where(scores >= 50, "fifty",
np.where(scores == 0, "duck", "-")))
print(labels)
bonus = np.where(scores >= 50, scores + 10, scores)
print(bonus)text
['fifty' '-' '-' 'duck' 'century' 'fifty' '-']
[ 82 8 45 0 113 62 19]Nested np.where works like if / elif / else; the first condition checked wins (a century is checked before a fifty). For many categories, np.select(condlist, choicelist, default) is cleaner.
Example — nested loops → broadcasting
python
scores = np.array([[72, 45, 88],
[56, 0, 74],
[45, 32, 0]])
pitch_factor = np.array([1.0, 1.2, 0.9]) # one per match (column)
print((scores * pitch_factor).round(1))
centered = scores - scores.mean(axis=1, keepdims=True)
print(centered.round(1))text
[[72. 54. 79.2]
[56. 0. 66.6]
[45. 38.4 0. ]]
[[ 3.7 -23.3 19.7]
[ 12.7 -43.3 30.7]
[ 19.3 6.3 -25.7]]A (3,) factor stretches across every row; keepdims=True keeps the row means as shape (3, 1) so they stretch across columns. No loops, no copies of the small arrays.
Example — never np.append in a loop (and preallocate)
python
import time
n = 20_000
t = time.perf_counter()
a = np.array([], dtype=np.int64)
for i in range(n):
a = np.append(a, i * 2) # copies the whole array every time!
t_append = time.perf_counter() - t
t = time.perf_counter()
b = np.empty(n, dtype=np.int64) # allocate once
for i in range(n):
b[i] = i * 2
t_prealloc = time.perf_counter() - t
t = time.perf_counter()
c = np.arange(n) * 2 # no loop at all
t_vec = time.perf_counter() - t
print(np.array_equal(a, b), np.array_equal(b, c))
print(f"np.append in loop : {t_append*1000:8.2f} ms")
print(f"preallocated loop : {t_prealloc*1000:8.2f} ms")
print(f"vectorised : {t_vec*1000:8.2f} ms")One real run (timings differ on every machine and every run):
text
True True
np.append in loop : 81.00 ms
preallocated loop : 2.26 ms
vectorised : 0.09 msHow it works: NumPy arrays have a fixed size. np.append cannot grow an array; it allocates a brand-new one and copies everything — so a loop of n appends copies about n²/2 elements (quadratic time). Preallocating with np.empty/np.zeros allocates once; the fully vectorised version doesn't loop at all. If you truly don't know the final size, append to a Python list (cheap) and call np.array(lst) once at the end.
Example — Python's sum() vs np.sum()
python
rng = np.random.default_rng(0)
x = rng.random(1_000_000)
# sum(x) -> loops in Python over 1,000,000 NumPy scalars
# np.sum(x) -> one C loopOne real run: Python sum(): 59.02 ms, np.sum(): 0.74 ms — about 80× faster. The same applies to max, min, any, all: use the NumPy versions on arrays.
Example — in-place operations save memory
python
x = np.arange(5, dtype=np.float64)
y = x # same object
x *= 2 # in place: no new array
print(y)
np.add(x, 1, out=x)
print(x)text
[0. 2. 4. 6. 8.]
[1. 3. 5. 7. 9.]x = x * 2 creates a new array and rebinds the name; x *= 2 overwrites the existing memory (y sees the change because it is the same object). On huge arrays this halves memory use.
Performance checklist
| Do ✅ | Avoid ❌ |
|---|---|
| Whole-array maths and ufuncs | for loops over elements |
np.where / np.select | if/else per element |
Boolean masks a[a > 0] | building filtered lists by hand |
Broadcasting with keepdims=True | nested loops over rows/columns |
Preallocate (np.zeros, np.empty) | np.append / np.concatenate inside loops |
arr.sum(), arr.max() | Python sum(), max() on arrays |
Right dtype (int32/float32 when enough) | object dtype arrays |
In-place ops (+=, out=) on huge arrays | needless temporary copies |
Note:
np.vectorize(f)lets you call a Python function on every element with array syntax, but it is still a Python loop inside — convenient, not fast.
Common mistakes
- ❌
arr = np.append(arr, x)inside a loop. ✅ List + onenp.array, or preallocate. - ❌ Believing
np.vectorizemakes code fast. ✅ Rewrite with real ufuncs /np.where. - ❌ Timing a single tiny array and concluding NumPy is slow. ✅ NumPy has a small per-call overhead; it wins massively on large arrays.
- ❌ Converting arrays back and forth to lists inside a loop. ✅ Stay in NumPy for the whole calculation.
Interview tip
Q: Why is np.append inside a loop slow?
A: NumPy arrays have a fixed size, so each np.append allocates a new array and copies all existing elements — O(n²) total work. Append to a Python list and convert once, or preallocate the array.
Key points to remember
- Vectorisation replaces Python loops with whole-array operations executed in compiled C.
- Ufuncs (+, *, np.sqrt, comparisons…) work element-wise on whole arrays at once.
- Replace per-element if/else with np.where (or np.select for many cases).
- Replace nested loops with broadcasting; keepdims=True keeps shapes aligned.
- Never np.append inside a loop — it copies the whole array each time (quadratic). Preallocate or use a list.
- Use arr.sum()/np.sum instead of Python's sum() — tens of times faster.
- np.vectorize is a convenience wrapper, not a speed-up.
Try it yourself
Loop vs vectorised timing
np.where and np.select instead of if/else
Build arrays the right way
Practice: vectorise this loop
Revision Flashcards
Tap a card to flip it and see the answer.
