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.
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.
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.
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.
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. |
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).
Strides explorer
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.
Broadcasting
Two rules, applied mechanically, and then one fact about implementation that makes it clear why broadcasting is free.
The procedure
- Right-align the shapes, padding the shorter one with 1s on the left.
- 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).
Broadcasting shape calculator
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 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.
What axis=0 actually does
The confusion here is universal and has one clean resolution.
The axis you name is the axis that disappears. A reduction along axiskcollapses dimensionkand 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.
NaN, and why it is not equal to itself
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
|
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.
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:
- Fetch and decode several bytecode instructions.
- Follow a pointer to a heap-allocated int or float object, most likely a cache miss.
- Dispatch through the type's multiplication slot, since the types are only known at runtime.
- Allocate a brand new object for the intermediate, and another for the result.
- Adjust several reference counts.
- 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
- One dispatch, at the whole-array level, resolved once.
- Allocate one output buffer of the right size, once.
- 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.
- No per-element objects and no refcounting at all.
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.
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.
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.
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.