00Overview 01Python semantics 02Python runtime 03NumPy 04pandas 05Operating systems 06Networking 07Resume project 0812-interview plan
03 · NumPy

Four fields
explain everything.

Rebuild this one from zero. An array is a pointer, a dtype, a shape and a strides tuple, and views, broadcasting, axes and the speed difference all fall straight out of those four things. Learn the layout and you stop needing to remember the rules.

Mechanism

What an ndarray actually is

Start here and the rest of the page is deduction rather than memorisation.

The four fields

Field Holds Example for a 3×4 float64 array
data A pointer to one flat block of raw bytes 96 contiguous bytes somewhere on the heap
dtype How to interpret each fixed-size chunk of those bytes float64, so 8 bytes per element
shape The logical dimensions (3, 4)
strides How many bytes to step to move one index along each axis (32, 8): one row is 32 bytes, one column is 8

Reading element a[i, j] is then a single formula, with no searching and no per-element metadata:

address = data + i*strides[0] + j*strides[1]

The shape does not have to match how the bytes are laid out, and the strides do not have to be positive or in descending order. That freedom is the entire trick. A great many operations are just a new shape and strides tuple pointing at the same bytes, which means they cost nothing and copy nothing.

a = np.arange(12).reshape(3, 4)
a.strides        # (32, 8)
a.T.strides      # (8, 32)   transpose just swaps them. no data moved.
a[::2].strides   # (64, 8)   every other row: double the row stride.
a[::-1].strides  # (-32, 8)  reversed: walk backwards. still no copy.
Contiguity is a property, not a guarantee

A freshly created array is C contiguous, meaning the last axis varies fastest and the bytes are in row-major order. Slicing and transposing can break that, which is why .reshape() sometimes has to copy and why np.ascontiguousarray exists. a.flags tells you where you stand.

Predict without running

Views and copies, derived rather than memorised

The rule people recite is "basic slicing gives a view, fancy and boolean indexing give a copy". True, but here is the reason, which is worth more than the rule because it tells you what to expect for cases you have not seen.

The actual rule If the elements you asked for can be described by an offset and a regular byte step along each axis, NumPy hands you a new array object over the same buffer. If they cannot, there is nothing to point at, so it has to gather them into a new buffer.

Apply it to each case:

Expression Result Because
a[2:8] view Offset 2 elements in, step 1. Regular.
a[::3] view Step of 3. Still regular, just a bigger stride.
a[::-1] view A negative stride is a perfectly good stride.
a.T, a.reshape(...) when possible view Reinterpreting the same bytes with different shape and strides.
a[[0, 3, 1]] copy The gaps are 3 then −2. No single step describes it.
a[a > 5] copy Which elements pass is data dependent, so the positions are arbitrary.
a[0, [1, 2]] copy Mixing basic and fancy still requires a gather.
a.reshape(...) across a non-contiguous slice copy The requested logical order is not expressible over the existing byte layout, so NumPy makes it contiguous first.
Predict before revealing
a = np.arange(10)

v = a[2:5]
v[0] = 99

f = a[[6, 7, 8]]
f[0] = 77

print(a)

[ 0 1 99 3 4 5 6 7 8 9]

The 99 landed in a because v is a window onto a's bytes, offset by 2 elements. Writing through the window writes through to the original.

The 77 did not, because f owns a fresh 3-element buffer that was filled by gathering positions 6, 7 and 8. It has no connection back to a.

Check any case with v.base is a, which is True for a view and None for a copy, or with np.shares_memory(a, f).

Widget

Strides explorer

One flat 24-byte buffer holding six int32 values laid out as a 3 by 2 array. Pick an operation and watch which bytes the result actually touches, what its shape and strides become, and whether NumPy could express it as a view or had to gather into new memory.

Shape
Strides, bytes
Offset
Verdict

Why this matters beyond trivia

Views are how you process a gigabyte without doubling memory, and they are how you accidentally corrupt data three functions away. A function that takes an array and writes into a slice of it has mutated its caller's data. If a function must not do that, it takes a.copy() at the top, and that copy is a deliberate, documented cost rather than an accident.

The mirror image is also worth having ready: a view keeps the whole original buffer alive. Slicing five elements out of a one-gigabyte array and keeping only the slice keeps the full gigabyte resident, because the base array cannot be freed while a view references it. If you are shrinking data to hold onto it, copy explicitly.

Predict without running

Broadcasting

Two rules, applied mechanically, and then one fact about implementation that makes it clear why broadcasting is free.

The procedure

  1. Right-align the shapes, padding the shorter one with 1s on the left.
  2. For each dimension, they must match or one must be 1. A 1 stretches to the other size. Anything else is an error.
  (3, 4)      (256, 256, 3)      (3, 1)       (3, 4)
  (   4)      (          3)      (   4)       (2, 4)
  -------      ---------------      -------      -------
  (3, 4)      (256, 256, 3)      (3, 4)       error: 3 vs 2

The third example is the one worth studying, because both operands stretch. Shape (3,1) against (4,) right-aligns to (3,1) and (1,4), the first stretches along the column axis, the second along the row axis, and the result is the full (3,4) outer combination. That is how you build a distance matrix or a pairwise difference in one line:

diffs = prices[:, None] - prices[None, :]   # (n, n) all pairwise differences

None in an index inserts a length-1 axis, which is the standard way to control which way a vector stretches. It is the same thing as np.newaxis and as reshape(-1, 1).

Widget

Broadcasting shape calculator

Type two shapes as comma-separated numbers. The alignment is shown dimension by dimension, marking each as matched, stretched, or the one that failed.

against

Why broadcasting costs nothing

The mental picture of "NumPy tiles the small array to match the big one" is wrong. To stretch a dimension of size 1, NumPy sets that dimension's stride to zero. The address formula then adds i * 0 for that axis, so every index along it reads the same bytes.

b = np.array([10, 20, 30])
np.broadcast_to(b, (1000, 3)).strides   # (0, 8)
# a thousand rows, and the buffer is still 24 bytes.

So adding a length-3 row vector to a million-row matrix allocates nothing for the row vector, no matter how many rows there are. There is no temporary of the broadcast shape. This is why broadcasting is the right way to write these operations rather than merely a convenient one.

The memory blow-up that is real

The inputs are free. The output is not. prices[:, None] - prices[None, :] on a 100,000-element vector produces a 100,000 by 100,000 float64 array, which is 80 GB. The broadcast itself allocated nothing, the result allocated everything. When broadcasting kills a process this is always why.

Because the rightmost axis is the innermost one, the one that varies fastest in memory, and it is conventionally the axis that carries the meaning of a single item: the three colour channels of a pixel, the four features of a row, the columns of a table. Leading axes are the ones you add as you batch things up.

Right-aligning means "match up the meaning, and treat everything to the left as batching". A per-channel scale applied to one pixel therefore works unchanged on an image, a batch of images, or a batch of batches, with no reshaping. Left-aligning would break that every time you added an outer dimension.

Because size 1 has exactly one unambiguous interpretation: this axis holds a single value that applies to everything along it. Stretching 2 into 4 would require choosing a rule, tiling or repeating each element, and both are defensible, which is precisely the problem. NumPy refusing to guess turns a class of silent shape bugs into loud errors.

It is also the only case implementable with a zero stride. Any other stretch would need real duplicated memory, so it would stop being free.

Because reading the same address a thousand times is harmless, and the value is in L1 cache after the first read, so it is close to free. Writing is another matter: a thousand logical positions mapping to one address makes the result order-dependent and meaningless. That is exactly why np.broadcast_to returns a read-only array, and why NumPy will not let you use a broadcast array as an assignment target.

Bedrock Broadcasting is stride manipulation, not data duplication. A stretched axis gets a stride of zero, so the same bytes are read repeatedly. That is why the rule is "size 1 only", why it is free, and why the result is read-only.
Predict without running

What axis=0 actually does

The confusion here is universal and has one clean resolution.

The rule that ends the confusion The axis you name is the axis that disappears. A reduction along axis k collapses dimension k and leaves every other dimension untouched.
a = np.array([[1, 2, 3],
              [4, 5, 6]])       # shape (2, 3)

a.sum(axis=0)   # [5, 7, 9]      shape (3,)  the 2 is gone: down the columns
a.sum(axis=1)   # [6, 15]        shape (2,)  the 3 is gone: across the rows
a.sum()         # 21             shape ()    everything is gone

Do not think "axis 0 is rows so it sums the rows". Think about the shape: (2, 3) with axis 0 removed is (3,), and the only way to get three numbers out of this array is one per column. The shape tells you the answer before you reason about it, every time, in any number of dimensions.

For a 3-D array of shape (days, symbols, features), arr.sum(axis=0) gives (symbols, features), one total per symbol and feature across all days. You did not need to picture the cube.

keepdims, and why it exists

a.sum(axis=1)                 # shape (2,)
a.sum(axis=1, keepdims=True)  # shape (2, 1)

a / a.sum(axis=1, keepdims=True)   # row-normalise: (2,3) against (2,1), broadcasts
a / a.sum(axis=1)                   # (2,3) against (2,) -> error, 3 vs 2

keepdims leaves the collapsed axis in place with size 1, which is exactly the shape broadcasting needs to stretch it back. Any time you reduce and then combine with the original, you want it.

Predict without running

NaN, and why it is not equal to itself

Predict before revealing
a = np.array([1.0, np.nan, 3.0])

print(np.nan == np.nan)
print(a.sum(), a.max())
print(a == np.nan)
print(np.nansum(a))
print(np.array([np.nan]) in [np.nan])

False; then nan nan; then [False False False]; then 4.0; then True.

Line 1 is the IEEE 754 rule. Line 2 is propagation: one NaN poisons the whole reduction, including max. Line 3 is the important one, because it is how people try to find NaNs and it silently finds nothing, not even the NaN itself. Line 4 skips them. Line 5 is the surprise: in and several container methods check identity before equality, so the same NaN object is found even though it does not equal itself.

NaN means "not a number", the result of an operation with no meaningful answer: 0/0, inf - inf, the square root of a negative. IEEE 754 defines it as unordered with respect to everything, so every comparison against it is false, including equality, and including against itself.

That is deliberate rather than perverse. If two calculations both failed, there is no basis for claiming they failed in the same way, so reporting them equal would be asserting something you do not know. Making all comparisons false also gives you a free NaN test in any language: x != x is true only for NaN.

You want Use Not
Find NaNs np.isnan(a) a == np.nan, which is always False
Reduce past them np.nansum, np.nanmean, np.nanmax, np.nanstd a.sum(), which returns NaN
Drop them a[~np.isnan(a)] filtering with a comparison
Replace them np.nan_to_num(a) or a[np.isnan(a)] = 0
Compare arrays that may hold NaN np.allclose(a, b, equal_nan=True) (a == b).all(), which is False wherever a NaN sits
Two consequences that cost money

First, NaN is a float concept. There is no NaN in an integer dtype, so putting one into an int array upcasts it to float, which is exactly why a pandas integer column becomes float the moment it acquires a missing value.

Second, sorting puts NaNs at the end because the comparisons are all false, so argsort silently ranks them last rather than erroring. A trading signal ranked with NaNs in it quietly treats missing data as the largest value. Check for NaNs before you rank anything.

Mechanism

Why vectorised beats a Python loop

Give the cost breakdown, not a memorised multiplier. Anyone can say "NumPy is faster". The answer that lands says where the time went.

What a Python loop does per element

out = []
for x in data:
    out.append(x * 2 + 1)

For every single element, the interpreter must:

  1. Fetch and decode several bytecode instructions.
  2. Follow a pointer to a heap-allocated int or float object, most likely a cache miss.
  3. Dispatch through the type's multiplication slot, since the types are only known at runtime.
  4. Allocate a brand new object for the intermediate, and another for the result.
  5. Adjust several reference counts.
  6. Grow the output list, occasionally reallocating and copying it.

The actual multiply is one CPU instruction out of perhaps a hundred. Everything else is the interpreter finding out what to do and managing objects.

What data * 2 + 1 does

  1. One dispatch, at the whole-array level, resolved once.
  2. Allocate one output buffer of the right size, once.
  3. Run a compiled C loop over contiguous memory. The dtype is known, so the operation is a fixed machine instruction with no dispatch. Sequential access lets the hardware prefetcher stay ahead. The compiler can use SIMD instructions to do four or eight elements at once.
  4. No per-element objects and no refcounting at all.
The answer, out loud In the loop, the interpreter spends nearly all of its time on per-element overhead: bytecode dispatch, unboxing an object, allocating a new one, and adjusting refcounts. The vectorised version pays that once for the whole array and then runs a compiled loop over contiguous memory, which the prefetcher and the vector units can both exploit. For simple elementwise arithmetic that is typically one to two orders of magnitude, and it comes from removing overhead rather than from a faster multiply.
Where the factor lands, and why it varies

Ten to fifty times is typical for simple arithmetic on medium arrays. Under a few hundred elements, NumPy's fixed per-call overhead dominates and a plain Python loop can genuinely win. Above cache size the gain shrinks toward the memory bandwidth limit, because at that point both versions are waiting on RAM and no amount of instruction efficiency helps. Quoting a range with the reason is better than quoting a number.

The thing that undoes it

An object dtype array. That is an array of pointers to Python objects, so every operation is back to per-element dispatch with the array machinery layered on top, which is slower than a plain list. It is also exactly what you get from a pandas column of mixed types or of Python strings under the older string handling. Check arr.dtype before concluding you have vectorised anything.

Fluent

The functions you should not have to look up

Each with the situation it belongs to, since recognising the situation is the part that is actually hard.

Shape and selection

a.reshape(3, 4)        # -1 for one axis means "infer it": reshape(-1, 4)
a.ravel()               # flatten, a view when it can be
a.flatten()             # flatten, always a copy
a[:, None]              # add a length-1 axis, to control broadcasting
np.concatenate([a, b], axis=0)
np.stack([a, b])        # adds a new axis; vstack/hstack are the 2-D conveniences

Selection and conditionals

a[a > 0]                     # boolean mask, returns a compacted copy
np.where(a > 0, a, 0)          # elementwise if/else, same shape out
np.where(a > 0)                # one argument: returns the indices instead
np.clip(a, lo, hi)             # winsorise, exactly what you want for outliers
np.select([c1, c2], [v1, v2], default=0)   # multi-branch where

mask = (a > 0) & (b < 5)         # & | ~, and the parentheses are mandatory

The parentheses are not style. & binds tighter than > in Python, so a > 0 & b < 5 parses as a > (0 & b) < 5 and does something else entirely. And and or or on arrays raises, because Python asks for a single truth value and an array cannot supply one.

Ordering and searching

np.argsort(a)              # the indices that would sort a; the workhorse for ranking
a[np.argsort(a)]           # same as np.sort(a)
np.argsort(scores)[::-1][:10]   # top 10 by score
np.argpartition(a, -10)[-10:]  # same top 10, O(n) instead of O(n log n), unordered
np.argmax(a), np.argmin(a)

np.searchsorted(sorted_arr, values)   # binary search: where would these insert?

searchsorted is the one worth internalising for this desk. It is the vectorised binary search, so it bins values into ranges, aligns one timestamp series to another, and underpins as-of joins. Millions of lookups against a sorted array in one call, in O(log n) each. Use side='right' to control which way ties fall.

Sequential structure

np.diff(a)                 # successive differences; output is one shorter
np.cumsum(a)               # running total; cumprod, cummax likewise
np.diff(a) / a[:-1]        # simple returns from a price series
np.cumsum(returns)         # cumulative log returns

Watch the length. diff returns n−1 elements, so pairing it back against the original needs an explicit slice or a prepended value. This off-by-one is the single most common source of a one-bar misalignment, which on the pandas page becomes the lookahead bias trap.

dtype choice

np.arange(10).dtype            # int64 on most platforms
np.array([1, 2], dtype=np.float32)  # half the memory, about 7 significant digits

np.array([200], dtype=np.int8) + np.array([100], dtype=np.int8)
# overflow. NumPy uses fixed-width machine integers, so it wraps.
# Python ints are arbitrary precision; NumPy ints are not.

This is a real difference in kind from pure Python, not a detail. Python integers grow without bound. NumPy integers are machine words that wrap silently on overflow. Note that NumPy 2.0 changed the promotion rules so that a Python scalar no longer upcasts the array's dtype, which makes overflow more likely to be visible in code written against NumPy 1.x expectations.

Choosing float32 over float64 halves memory and roughly doubles how much fits in cache, which often matters more than the arithmetic. The cost is about 7 decimal digits of precision instead of about 16. For prices and for accumulating sums over millions of rows, keep float64. For features fed into a model, float32 is usually fine and considerably faster.