dtype is the cheapest saving there is
Half the width, half the memory, and usually faster because less has to travel.
Temporaries are the hidden cost
Every intermediate expression allocates a full-size array you never see.
out= writes into memory you already have
The explicit form of the same idea, and it works with any ufunc.
Strided access costs more than contiguous access
Same number of bytes, same result - only the order they are read in differs.
Preallocate instead of growing
The same lesson as concatenating in a loop, stated as a habit.
Measuring rather than guessing
nbytes for size, and a timing loop for anything you are about to optimise.
dtype
float64 is the default and is often more precision than the data justifies. Sensor readings good to three significant figures do not benefit from fifteen.
float32 keeps about seven significant digits, halves the memory, and typically runs faster — not because the arithmetic is cheaper, but because half as much data has to travel from memory to the processor, and memory bandwidth is usually the limit.
The same applies to integers. An array of small counts does not need int64. An array of flags should be bool_, which is one byte, not int64, which is eight.
The caution from the dtypes module still applies: narrow integers overflow silently. Choose the smallest type that cannot overflow for your actual data, not the smallest that happens to fit today's sample.
Temporaries
a * 2 + 1 on a million-element array does not do one pass. It allocates an 8 MB temporary for a * 2, then allocates another 8 MB for the final result, then frees the first.
You never see them, and for a single expression it rarely matters. In a loop, or in a chain of five operations on a large array, the allocation and the extra memory traffic dominate.
Two ways to avoid it.
In-place operators. a *= 2 modifies the existing buffer and allocates nothing. Remember that it also modifies anything sharing that buffer, which is the views-versus-copies problem again.
out=. Every ufunc accepts out, which names the destination explicitly. np.multiply(a, 3, out=dest) writes into dest, and out may be one of the inputs. This is the clearest form when you are accumulating into a buffer across iterations.
Neither is worth doing everywhere. a * 2 + 1 is more readable than two statements, and readability wins until you have measured that this line matters.
Layout and the cache
A C-ordered array stores the last axis contiguously. Consecutive elements along that axis sit next to each other in memory.
Processors fetch memory in cache lines, so reading consecutive bytes is far cheaper than jumping a row at a time.
Reductions are a poor demonstration of this. NumPy blocks them internally, so sum(axis=0) and sum(axis=1) usually run at similar speed and sometimes in the order you would not predict. It is worth knowing that, because the "always reduce along the last axis" advice is repeated widely and does not survive measurement.
Where it shows clearly is when data is actually moved. Copying a (1200, 1200) array and copying its transpose move the same number of bytes and produce the same number of bytes, but the transpose reads one value from each row in turn, and runs several times slower.
Two practical consequences. If you have a choice about which axis holds the thing you iterate over, put it last. And if you are about to make many strided passes over the same data, one np.ascontiguousarray up front can pay for itself — a single copy against many slow reads.
Preallocation
np.append and np.concatenate do not append. They allocate a new array and copy everything. In a loop that is quadratic, and it is the single most common accidental slowdown in NumPy code after the plain Python loop.
If you know the final size, np.empty(n) and assignment by index is the direct answer. np.empty does not initialise, so it is faster than np.zeros when you are going to overwrite everything — and dangerous if you are not, because the contents are whatever was in that memory.
If you do not know the size, collect into a Python list and convert once at the end. Lists are designed for growth; arrays are not.
Measuring
Guessing is unreliable, and the intuitions transferred from pure Python are often wrong.
a.nbytes gives the real size of the data. a.itemsize gives the per-element cost.
For time, run the operation several times and take the minimum, not the mean. The slow runs are measuring interference from the rest of the machine; the fastest run is the closest estimate of the actual cost. A single timed run of a fast operation measures mostly noise.
And measure the thing you intend to change. Optimising an operation that accounts for 2% of the runtime is effort spent for a 2% ceiling, however satisfying the local speedup looks.
Views and memory that will not go away
A view holds a reference to the array it came from, so the entire base buffer stays alive as long as the view does.
Extract one row from an 8 MB array, keep it, and you are keeping 8 MB.
This is the most common source of NumPy memory that never comes back, and it is invisible — row.nbytes reports four kilobytes while the process holds eight megabytes.
The fix is copy() at the point you decide to keep something small from something large. The cost is one small allocation; the saving is the whole base.
a.base shows what a view derives from, and a.base.nbytes shows what is actually being held. That is the diagnostic when memory in a long-running process grows without an obvious cause.
Threads and the GIL
NumPy releases the GIL during most array operations, which means threads genuinely run in parallel inside a NumPy computation — unlike most pure Python code.
That makes concurrent.futures.ThreadPoolExecutor a real option for parallelising array work, with none of the pickling and process-startup costs of multiprocessing.
Two caveats. Operations that touch Python objects — object arrays, anything calling back into Python — do not release it. And BLAS routines are usually already multi-threaded internally, so adding your own threads on top can oversubscribe the machine and run slower. OMP_NUM_THREADS and threadpoolctl control that layer.
Measure before assuming threading helps. For large matrix operations it often does nothing, because BLAS was already using every core.
Chunking
When an operation would allocate more than fits in memory, the answer is usually to do it in pieces.
The classic case is a pairwise distance matrix. For 100,000 points, the intermediate is 10 billion entries and no machine will hold it. Processing 1,000 rows at a time keeps the intermediate at 100 million and gives the same answer.
The general shape:
for start in range(0, n, chunk):
block = data[start:start + chunk]
out[start:start + chunk] = expensive(block)
The slices are views, so the chunking itself allocates nothing, and the output is preallocated once.
This is one of the cases where a loop is correct. The loop runs n / chunk times, not n times, so the interpreter overhead is negligible and the memory ceiling is the thing being controlled.
When to reach past NumPy
NumPy has a ceiling, and recognising it saves effort spent optimising against it.
Numba compiles a Python function to machine code with a decorator. It is the right answer for genuinely sequential algorithms — the ones that cannot be vectorised — and often gets within range of C for a few lines of change.
Cython gives more control at the cost of a build step, and is what several scientific libraries use internally.
SciPy already contains a compiled implementation of a great many things people write by hand: distance matrices, sparse matrices, signal filters, optimisation, interpolation. Checking whether SciPy has it is cheaper than writing it.
Dask handles arrays larger than memory with a NumPy-like interface, splitting work into chunks automatically.
CuPy / PyTorch move the work to a GPU, with an interface close enough to NumPy that porting is often mechanical. Worth it for large array workloads, not worth it for small ones where the transfer cost dominates.
The order to consider them: is it vectorisable in NumPy, is it in SciPy, is it sequential and worth Numba, is it too big for memory, is it big enough for a GPU.
Profiling before optimising
The instinct about where time goes is unreliable, and array code is no exception.
cProfile gives function-level timings and finds the hot function. line_profiler gives per-line timings within it, which is what you actually need when one function contains the whole computation.
memory_profiler tracks allocation line by line, and a.nbytes summed over the arrays you are holding is a quick manual version.
Two rules make profiling worth the time.
Profile the real workload. A toy input can have completely different characteristics — different cache behaviour, different branch of the algorithm, different memory pressure.
Fix the top item, then measure again. The bottleneck moves. Optimising the second item on the original list is often wasted, because after the first fix it is no longer second.
And keep the ceiling in view: a function taking 5% of runtime cannot give more than a 5% improvement however completely you optimise it.
Where the wins actually are, in order
Remove the Python loop. 10–100x, and it dominates everything else. If a loop over elements is still there, nothing below this line matters yet.
Avoid quadratic growth. np.append or concatenate in a loop turns a linear job into a quadratic one. The fix is preallocation or a list.
Right-size the dtype. Up to 2x on bandwidth-bound work, and it halves memory.
Remove temporaries in hot paths. In-place operators and out=. Worth doing where measurement says it matters, not everywhere.
Fix access patterns. Contiguity, and avoiding repeated strided gathers.
Reach past NumPy. Numba, SciPy, a GPU — when the algorithm genuinely does not vectorise or the data genuinely does not fit.
Working down that list in order means the large wins come first, and it avoids the common trap of micro-optimising an expression inside a loop that should not exist.
Things that look slow and are not
Slicing. A view. Free, whatever the array size.
Transposing. A view. Free.
Reshaping a contiguous array. A view. Free.
Reversing with [::-1]. A view. Free.
Broadcasting the inputs. No allocation; the strides are set to zero. The *output* is allocated, which is a different thing.
Rewriting any of these to "avoid the copy" is effort spent on a copy that was never happening. Confirm with np.shares_memory before optimising something that may already be free.
Things that look cheap and are not
Boolean masking and fancy indexing. Both allocate, and both gather from scattered positions.
astype. Allocates a full second array, even when the dtype is unchanged, unless copy=False.
Chained expressions on large arrays. Every intermediate is a full-size allocation.
np.append. Not an append. A full copy, every call.
Keeping a small view of a large array. Holds the whole base alive.
The discipline
Measure before optimising, and profile the real workload rather than a small stand-in — cache behaviour and memory pressure do not scale down predictably.
Take the minimum of several timed runs, not the mean.
Fix the top item and measure again, because the bottleneck moves.
Keep the ceiling in view: an operation taking 5% of the runtime cannot yield more than 5%, however thoroughly it is optimised.
And weigh readability honestly. A vectorised one-liner that nobody can safely modify has a maintenance cost that does not show up in a benchmark, and the version a colleague can read is often the better engineering answer even when it is slower.