Best Practices
After the full tour, the best practices condense to a handful of habits that separate NumPy code that is correct, fast, and maintainable from code that merely "works." This chapter collects them: shape discipline as the first line of defense, vectorization as the default, dtype as a decision rather than an accident, and a performance rule set that applies only after measurement. These are the checks to run before and after writing any numerical function.
Shape Discipline
import numpy as np
def scale_rows(x):
# 1. assert the contract at the boundary
assert x.ndim == 2, f"expected 2-D, got shape {x.shape}"
# 2. do the work
s = x.sum(axis=1, keepdims=True)
out = x / s
# 3. verify the contract on the way out
assert out.shape == x.shape
return out
# the habit: every nontrivial function checks its input shape
# and its output shape - a bad shape fails loudly at the edge,
# not three calls later inside a broadcasting error
Most NumPy bugs are shape bugs, and the cheapest fix is to check shapes at function boundaries. An assert x.ndim == 2 or assert x.shape == (n, d) at the top, plus a shape check on the return, turns a cryptic broadcasting error deep in the pipeline into a clear failure at the edge. Print or assert the shape of any intermediate you are unsure about while developing. This one habit eliminates most "why is this (4,3) and not (3,4)" debugging sessions.
Vectorize by Default
- Express computation on whole arrays - if a Python loop applies the same operation to every element or row, it is a ufunc or a broadcasting expression in disguise.
- Use masks for conditional logic -
x[x < 0] = 0ornp.where(cond, a, b)instead of anifper element. - Use
out=and augmented assignment in hot loops to avoid temporary allocations. - Prefer
@and the linalg routines over manual index arithmetic for products and solves.
The default is the array operation; the Python loop is the exception, justified only by control flow the engine cannot express. When you catch yourself writing for i in range(n): out[i] = ... with the same math each iteration, stop - there is almost always a vectorized form, and it is the one that scales.
Treat dtype as a Decision
- Default to float64 for computed values; drop to float32 only as a deliberate, tested memory/speed trade.
- Never let a dtype be an accident - integer division is the classic silent-precision bug; pin the dtype at creation when it matters.
- Match the dtype to the data - int32 for bounded counts, float64 for measurements, bool for masks - and the memory adds up across a pipeline.
Dtype errors are silent: the code runs and returns a plausible-looking number with the wrong type or precision. The discipline is to state the dtype explicitly at array creation and to check it whenever a value "looks off." An integer division that should have been floating-point is the canonical example - the fix is / versus // and an explicit astype.
Performance Rules, in Order
Apply these only after measuring the real expression at the real shape - in the order of their typical payoff:
- Replace a Python loop with a ufunc - the 50-100x win, by far the largest.
- Eliminate hot-loop allocations with
out=and preallocation - the allocator is often the cost, not the math. - Drop float64 to float32 where precision allows - halves memory and can halve runtime.
- Fix the layout - feed a layout-sensitive routine a contiguous array (
ascontiguousarray) or the order it wants. - Use
einsumfor non-trivial contractions that a reshape-plus-matmul chain would express awkwardly.
The rule that governs all of it: measure first. time.perf_counter around the real expression, best of a few reps, before changing a line. The "obviously slow" line is rarely the real cost, and the optimization that does nothing is the most expensive one - it costs you the clarity you gave up for no gain.
Reading and Reviewing NumPy Code
- Read the shape of every expression - annotate
(m, k) @ (k, n) -> (m, n)in your head; a mismatch is the bug. - Check the dtype - an int where a float is expected (or vice versa) changes the arithmetic.
- Spot the copies - fancy indexing,
astype, and arithmetic allocate; a view is free. Know which you are holding. - Look for the implicit loop - a list comprehension or
forover elements is a vectorization waiting to happen. - Verify the numerical check - is symmetry, normalization, or a residual actually asserted, or merely assumed?
Reviewing NumPy code is mostly shape and dtype annotation. If you can state the shape and dtype of every line without running it, you understand it; if you cannot, that is the line to test. The numerical assertions - "this is symmetric," "these rows sum to 1" - are the ones to demand, because they are the invariants the math depends on.
Summary
The practice of NumPy is a small set of habits: assert shapes at every boundary, vectorize by default and loop only for real control flow, treat dtype as an explicit decision, and apply performance rules only after measuring - loop-to-ufunc, then allocations, then dtype width, then layout. Reading code is shape and dtype annotation; if you cannot state both for a line without running it, that is the line to test. These habits are what turn a working snippet into numerical code that is correct, fast, and maintainable - the through-line of the entire tutorial, from the first np.array to the last linalg call.