NumPy Arrays — dtype, shape, strides
What an ndarray actually is in memory — the prerequisite to making any of it fast.
TL;DR
A NumPy ndarray is a Python object wrapping three things: a data
buffer (a contiguous chunk of bytes), a dtype (how to interpret
those bytes — float32, int64, etc.), and a shape + strides pair
(how to map an N-D index into a byte offset in the buffer). That’s
it. Everything fast about NumPy is downstream of this representation.
The buffer is C-contiguous memory. NumPy operations dispatch to
hand-written C / SIMD / BLAS routines that read this buffer directly,
without ever materialising a Python object per element. That’s why
a + b on two million-element arrays takes 1 ms while [x + y for x, y in zip(a_list, b_list)] takes ~200 ms.
You won’t use NumPy primitives directly all day — you’ll use PyTorch tensors, which are essentially the same idea with a few extra bits (autograd, GPU device). But the dtype/shape/strides mental model is universal across the numerical stack.
The picture in your head
An ndarray(shape=(3, 4), dtype=float64) is twelve float64 numbers
laid out in a 96-byte buffer. The shape (3, 4) says “treat this as
3 rows of 4 elements.” The strides say “to advance one row, jump 32
bytes; to advance one column, jump 8 bytes.” Indexing a[i, j] is
just *(buffer + i*32 + j*8), computed in C with no Python overhead
per element.
buffer: [b00 b01 b02 b03 b10 b11 b12 b13 b20 b21 b22 b23]
indices: [0,0|0,1|0,2|0,3|1,0|1,1|1,2|1,3|2,0|2,1|2,2|2,3]
strides: row stride = 4 * 8 = 32 bytes; col stride = 8 bytes
This layout is row-major (C order). NumPy supports column-major (Fortran order) too, but row-major is the default and what you should assume.
Inspecting an array
import numpy as np
a = np.arange(12, dtype=np.float32).reshape(3, 4)
>>> a
array([[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.]], dtype=float32)
>>> a.shape
(3, 4)
>>> a.dtype
dtype('float32')
>>> a.strides # bytes to step per axis
(16, 4) # row stride = 4 * 4 bytes; col stride = 4 bytes
>>> a.itemsize
4 # bytes per element
>>> a.nbytes
48 # 12 * 4
>>> a.ndim
2
>>> a.size
12
>>> a.flags['C_CONTIGUOUS']
True
The four attributes you should know cold: shape, dtype, ndim,
nbytes. The strides matter when you start asking “why is this slice
fast / slow” — strides are how NumPy implements free slicing, and
non-contiguous strides are why some operations are surprisingly slow.
Dtypes
NumPy supports many dtypes; you’ll use a small set.
| Category | Dtype | Bytes | Notes |
|---|---|---|---|
| Float | float16, float32, float64 | 2 / 4 / 8 | float32 is the ML default. float64 doubles memory and bandwidth. |
| Float (BF) | bfloat16 | 2 | Wider exponent range than float16; the standard for ML training. NumPy 2.0+. |
| Int | int8, int16, int32, int64 | 1 / 2 / 4 / 8 | int64 is Python’s int default. int32 halves memory. |
| Uint | uint8 | 1 | Pixel values, byte data. |
| Bool | bool_ | 1 | Logical arrays. |
| Complex | complex64, complex128 | 8 / 16 | Signal processing, FFTs. |
The single most common ML mistake: leaving arrays as float64 when
float32 (or smaller) is enough. float64 doubles your memory footprint
and halves your effective bandwidth; on GPUs it’s often 32× slower
than float32 (consumer cards barely accelerate fp64). When you load
data, set the dtype:
features = np.array(rows, dtype=np.float32) # half the memory of default
labels = np.array(targets, dtype=np.int32) # ample for class IDs
# DON'T:
features = np.array(rows) # defaults to float64
Shape — the way you reason about arrays
| ndim | Common name | Typical ML use |
|---|---|---|
| 0 | scalar | A loss value. |
| 1 | vector | A single embedding, a single example’s features. |
| 2 | matrix | A batch × features array, or a vocab × dim embedding table. |
| 3 | rank-3 tensor | (batch, sequence, dim) — Transformer hidden states. |
| 4 | rank-4 tensor | (batch, channels, height, width) — image tensors in CHW order. |
| 5+ | higher rank | Video (batch, time, channels, h, w), nested attention, etc. |
Three reshape operations to know cold:
a = np.arange(24) # shape (24,)
a.reshape(4, 6) # explicit: 24 elements -> 4x6
a.reshape(4, -1) # -1 means "infer": same as (4, 6)
a.reshape(-1) # flatten to 1-D
# Adding / removing length-1 axes
a.reshape(4, 6)[:, :, None] # (4, 6) -> (4, 6, 1)
a[None, :] # (24,) -> (1, 24)
a.squeeze() # drop all length-1 axes
np.expand_dims(a, axis=0) # explicit length-1 axis insertion
a[None, :] and a[:, None] are the standard idiom for inserting a
length-1 axis — useful when broadcasting (see PY 202).
reshape returns a view of the same buffer when possible, no copy.
If the requested shape is incompatible with the existing strides
(rare, but happens), it falls back to a copy.
Views vs copies
Slicing an array returns a view — same buffer, different shape and strides. Mutating a view mutates the underlying array.
a = np.arange(12).reshape(3, 4)
b = a[:, 1:3] # view of two middle columns
>>> b.base is a
True # b is backed by a
>>> b[0, 0] = 99
>>> a
array([[ 0, 99, 2, 3], # modified through the view
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
To get an independent copy: b = a[:, 1:3].copy().
Fancy indexing (with arrays of indices or boolean arrays) returns a copy, not a view:
a = np.arange(12).reshape(3, 4)
b = a[[0, 2]] # rows 0 and 2 — this is a COPY
b[0, 0] = 99
>>> a[0, 0]
0 # unchanged
Knowing what’s a view and what’s a copy is how you avoid both bug-by-aliasing and accidental-doubled-memory.
Strides and the geometry of slicing
Slicing changes shape and strides without copying data. This is what lets you take “every other row” essentially for free.
a = np.arange(24).reshape(4, 6)
>>> a.strides
(48, 8) # rows: 6 * 8 = 48 bytes; cols: 8 bytes
>>> a[::2].strides # every other row
(96, 8) # row stride doubled, no data copy
>>> a.T.strides # transpose: just swap the strides!
(8, 48)
a.T (transpose) returns a view with swapped strides. The data buffer
is unchanged. This is why transpose is O(1) — no work, just a new
metadata pair. The catch: many subsequent operations require contiguous
memory and will silently copy. If you transpose-then-write or
transpose-then-pass-to-a-BLAS-routine, you may pay a copy cost. Use
np.ascontiguousarray(a.T) if you want to materialise it.
Constructors — making arrays
| Function | Purpose |
|---|---|
np.array([...]) | From a Python list / nested list. |
np.zeros(shape, dtype) | Pre-filled with zeros. |
np.ones(shape, dtype) | Pre-filled with ones. |
np.empty(shape, dtype) | Uninitialised memory — fastest, but garbage data. |
np.full(shape, fill_value, dtype) | Pre-filled with arbitrary value. |
np.arange(start, stop, step) | Like Python range. |
np.linspace(start, stop, n) | n evenly-spaced points. |
np.eye(n) | Identity matrix. |
np.random.default_rng(seed) | Modern RNG; .standard_normal((m,n)), .integers(...), etc. |
np.frombuffer(bytes, dtype) | Wrap a byte buffer (no copy). |
Use the modern RNG API (np.random.default_rng) and not the legacy
global np.random.rand / np.random.seed. The modern one is a real
PRNG object you can pass around, seed independently, and run in
parallel without contention.
rng = np.random.default_rng(42)
weights = rng.standard_normal((128, 64), dtype=np.float32)
labels = rng.integers(0, 10, size=128)
Memory layout — C order vs F order
NumPy’s default is C order (row-major): the rightmost axis varies fastest in memory. Some libraries (LAPACK, certain GPU kernels) prefer F order (column-major). NumPy lets you specify:
a_c = np.zeros((3, 4), order="C") # default: row-major
a_f = np.zeros((3, 4), order="F") # column-major
>>> a_c.strides
(32, 8) # 4 cols * 8 bytes per row
>>> a_f.strides
(8, 24) # 1 byte per row * 8; 3 rows * 8 per col
Most of the time the default is right. Pay attention when interfacing with C++/Fortran/CUDA libraries that document a preferred order.
A worked example — feature matrix shapes
A common ML task: turn a list of (text, label) pairs into a
feature matrix and label vector.
import numpy as np
from sentence_transformers import SentenceTransformer
texts: list[str] = [...] # 10,000 strings
labels: list[int] = [...] # 10,000 ints
encoder = SentenceTransformer("all-MiniLM-L6-v2")
# Encode in batches; encoder returns float32 numpy arrays of shape (N, 384)
embeds_list = []
batch_size = 256
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
embeds_list.append(encoder.encode(batch))
X = np.vstack(embeds_list) # (10000, 384), dtype=float32
y = np.array(labels, dtype=np.int32) # (10000,)
>>> X.shape, X.dtype, X.nbytes
((10000, 384), dtype('float32'), 15360000) # ~15 MB
>>> y.shape, y.dtype, y.nbytes
((10000,), dtype('int32'), 40000) # 40 KB
Notice the dtype choices. float32 halves what float64 would cost
(30 MB → 15 MB). For 10k examples this doesn’t matter, for 10M it
matters enormously. int32 is plenty for class IDs; int64 would
double the labels.
Common gotchas
- Default
int64/float64everywhere. Be explicit about dtypes when constructing arrays, especially for large datasets. np.array([1, 2.0, "3"])makes a string array. NumPy unifies to the most general dtype it can find. Filter / clean Python lists before constructing.- Implicit upcasting.
int32 + int64 → int64,float32 + float64 → float64. Mixing dtypes silently doubles your memory. - Loops over arrays in Python. Each iteration is a Python-level scalar with full object overhead. ~100× slower than vectorized NumPy. See PY 203.
- Aliased writes.
b = a[:, 1:3]; b[:] = 0zeroes those columns ina. If that surprises you, you wanted.copy(). np.matrixis a legacy class. Use plainndarrayand@for matrix multiplication.
Where this shows up in real ML codebases
torch.Tensor— same dtype/shape/strides model.tensor.dtype,.shape,.stride()give you the same info, plus.device.- HDF5 / Zarr / NPY — disk formats that store NumPy arrays directly.
np.load("file.npy", mmap_mode="r")memory-maps the file without reading it. - Image pipelines —
uint8in HWC for raw images,float32in CHW after normalisation. The dtype dance is constant. - PyArrow / Polars — columnar formats that interoperate with NumPy via shared buffers (zero-copy).
The defensive habit: at array-construction time, always set the dtype. At every reshape, write the resulting shape as a comment. The shape is the contract.
Resources
- NumPy docs — ndarray — numpy.org — canonical reference.
- NumPy docs — internals & memory layout — numpy.org — strides, contiguity, buffer protocol.
- From Python to Numpy — Nicolas Rougier — labri.fr — free book that goes deep on memory layout.
- NumPy quickstart — numpy.org — for the parts of the API you don’t know yet.