Skip to content

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.

CategoryDtypeBytesNotes
Floatfloat16, float32, float642 / 4 / 8float32 is the ML default. float64 doubles memory and bandwidth.
Float (BF)bfloat162Wider exponent range than float16; the standard for ML training. NumPy 2.0+.
Intint8, int16, int32, int641 / 2 / 4 / 8int64 is Python’s int default. int32 halves memory.
Uintuint81Pixel values, byte data.
Boolbool_1Logical arrays.
Complexcomplex64, complex1288 / 16Signal 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

ndimCommon nameTypical ML use
0scalarA loss value.
1vectorA single embedding, a single example’s features.
2matrixA batch × features array, or a vocab × dim embedding table.
3rank-3 tensor(batch, sequence, dim) — Transformer hidden states.
4rank-4 tensor(batch, channels, height, width) — image tensors in CHW order.
5+higher rankVideo (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

FunctionPurpose
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 / float64 everywhere. 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[:] = 0 zeroes those columns in a. If that surprises you, you wanted .copy().
  • np.matrix is a legacy class. Use plain ndarray and @ 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 pipelinesuint8 in HWC for raw images, float32 in 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 — ndarraynumpy.org — canonical reference.
  • NumPy docs — internals & memory layoutnumpy.org — strides, contiguity, buffer protocol.
  • From Python to Numpy — Nicolas Rougierlabri.fr — free book that goes deep on memory layout.
  • NumPy quickstartnumpy.org — for the parts of the API you don’t know yet.