NumPy · Module 2 of 13 · 6 concepts · about 9 min

Understanding Arrays & Shapes

What an array actually is: one block of memory, one dtype, one shape. Almost every NumPy surprise is one of those three being different from what you assumed.

At work: Knowing what an array really is, so a batch of images, a year of prices or a table of features behaves the way you expect.

The ndarray

What it is

A fixed-size, n-dimensional grid of values that all share one dtype, stored as a single contiguous block of memory with a shape describing how to read it.

Why it matters

Because the values are unboxed and adjacent, a whole array can be processed in compiled code with no Python object per element. That is the entire performance story, and it is also why an array cannot grow in place the way a list can.

In the real world: A year of hourly electricity readings for 1,000 homes is one array: fast to average, compare and plot, unlike a list of lists.

Visual example

NumPy ndarray

An array is one block of memory plus a shape: no per-row labels, no per-column dtypes, just positions.

Axis 0: rows

(length 2)

Axis 1: columns

(length 3)

arr
012
0123
1456

arr[1, 2]

one scalar: row 1, column 2

arr[1]

the whole row, as a view

arr.shape → (2, 3)

The same array, described

arr.shape → (2, 3)

arr.dtype → int64

arr.size → 6

arr.nbytes → 48

Why this matters

Shape, dtype and contiguity are the three things that decide whether an operation is legal, exact, and fast. Reading an array as a picture of those three is what makes NumPy predictable.

Common misconception

Wrong idea: an ndarray is a list of lists.

Correct idea: it is a flat buffer of same-dtype values with a shape laid over it. arr[1, 2] is arithmetic on one index, not two lookups.

  1. 1One bufferSix int64 values, adjacent in memory.
  2. 2One shape(2, 3) says how to fold that buffer into rows and columns.
  3. 3One index per axisarr[1, 2] reads position 1 * 3 + 2 of the buffer.
import numpy as np

arr = np.array([[1, 2, 3], [4, 5, 6]])
arr.shape      # (2, 3)
arr.dtype      # dtype('int64')
arr[1, 2]      # 6 — one index per axis, not arr[1][2]

Treating an array like a list: arr.append(x) does not exist, and np.append(arr, x) allocates a whole new array every call. Building an array by repeated appending in a loop is quadratic. Collect into a Python list and call np.array once, or preallocate with np.empty and fill by index.

dtype

What it is

The single element type shared by every value in an array: int64, float32, bool, datetime64[ns], or object for a fallback array of Python pointers.

Why it matters

The dtype decides memory footprint, arithmetic precision, and whether an operation is done in compiled code at all. Halving a feature matrix from float64 to float32 halves its memory and usually costs nothing.

In the real world: Storing a billion sensor readings as 32-bit instead of 64-bit numbers halves the memory and storage bill with no useful loss of precision.

np.array([1, 2, 3]).dtype          # int64 on Linux, int32 on some Windows builds
np.array([1, 2.5]).dtype           # float64 — one dtype has to cover both
np.zeros(3, dtype=np.float32).dtype
x = np.array([1, 2, 3], dtype='int8')
x + np.int8(127)                   # overflows silently, wraps around

Integer arrays overflow silently rather than promoting to a bigger type. np.array([2**62], dtype='int64') * 4 wraps to a negative number with no warning, and an int8 counter of events wraps at 127. Declare the width you need, or work in floats.

Practise it: One-Hot Encode a Label Vector, Moving Average Without a Loop

shape, ndim and size

What it is

shape is the tuple of lengths per axis, ndim is how many axes there are (len(shape)), and size is the total element count (the product of the shape).

Why it matters

Nearly every NumPy error message is about a shape, and nearly every wrong answer that does not error is about a shape that happened to broadcast. Printing shapes is the debugging technique for this whole library.

In the real world: A batch of 32 product photos, each 224 by 224 pixels in colour, has shape (32, 224, 224, 3). Getting this wrong is the most common model-input error.

x = np.zeros((32, 128))
x.shape, x.ndim, x.size        # ((32, 128), 2, 4096)
np.zeros(5).shape              # (5,)   — 1-D, not (5, 1) and not (1, 5)
np.zeros((5, 1)).shape         # (5, 1) — a column

Confusing (5,) with (5, 1). A 1-D array has no orientation, so it broadcasts as a *row*; scikit-learn and most matrix code expect (5, 1). This is the source of the majority of accidental (5, 5) results.

Practise it: Flatten a Batch of Images

Axes, and what axis= means

What it is

An axis is a direction through the array, numbered from the outside in: axis 0 runs down the rows, axis 1 across the columns. For a reduction, axis= names the axis that is *collapsed*.

Why it matters

Getting this backwards produces an array of the right kind and the wrong meaning (a per-column mean where you wanted a per-row mean) and nothing raises. It is the most expensive silent error in NumPy.

In the real world: In a stores-by-days sales table, summing down the rows gives total sales per day; across the columns, total sales per store.

Visual example

Axis semantics

axis= names the axis that disappears: axis=0 collapses the rows, leaving one value per column.

axis=1

(across one row)

x.sum(axis=0)

collapse the rows → shape (2,)

x: shape (3, 2)
ab
012
134
256

x.sum(axis=1)

collapse the columns → shape (3,)

The named axis is the one lost

Both reductions, worked

axis=0 → [1+3+5, 2+4+6] = [9, 12]

axis=1 → [1+2, 3+4, 5+6] = [3, 7, 11]

Why this matters

Standardising a feature matrix needs a per-column mean; normalising a row of logits needs a per-row sum. The two differ by one character and nothing raises when you pick the wrong one.

Common misconception

Wrong idea: axis=0 means 'along the rows', so it gives one number per row.

Correct idea: axis=0 removes axis 0. Three rows and two columns reduced along axis 0 leaves two numbers: one per column.

  1. 1Name the axisaxis=0 is the row axis, axis=1 the column axis.
  2. 2Delete itThe result's shape is the input's shape with that entry removed.
  3. 3Check the length(3, 2) reduced on axis 0 gives (2,); on axis 1, (3,).
x = np.array([[1, 2], [3, 4], [5, 6]])
x.sum(axis=0)     # array([ 9, 12]) — one value per column, shape (2,)
x.sum(axis=1)     # array([ 3,  7, 11]) — one value per row, shape (3,)
x.sum()           # 21 — every axis collapsed
x.mean(axis=-1)   # the last axis, whatever ndim x has

Reading axis=0 as 'work along the rows'. It means 'eliminate axis 0', so the surviving shape is x.shape[1:]. When you cannot remember, compute the answer's shape first: the answer's shape tells you which axis you asked to lose.

Views, strides and memory layout

What it is

A slice does not copy. It returns a view: a new shape and stride pair over the *same* buffer. Strides are the byte steps to take per axis, which is how a transpose or a reshape can be free.

Why it matters

It explains both the speed and the aliasing bugs: b = a[:, 0] is instant and shares memory, so b[0] = 0 edits a. Knowing when you hold a view is knowing when a write is local.

In the real world: Taking last week's slice of a huge price array for a chart costs nothing, because no data is copied, so dashboards stay fast.

a = np.arange(6).reshape(2, 3)
a.strides          # (24, 8): one row is 24 bytes on, one column 8
b = a[:, 1]        # a view, no copy
b[0] = 99
a[0, 1]            # 99 — the write went through to a
a.T.strides        # (8, 24) — a transpose just swaps the strides
b.base is a        # True: b does not own its data

Assuming a function that 'returns an array' returned a fresh one. Passing a slice into code that modifies its input in place mutates the original. If you need independence, say so with .copy().

Practise it: Flatten a Batch of Images, Pick the Rows That Pass a Threshold

Arrays versus Python lists

What it is

A list is a resizable array of pointers to arbitrary Python objects. An ndarray is a fixed-size block of raw values of one type.

Why it matters

Everything else follows: arrays cannot hold mixed types without falling back to object, they cannot grow cheaply, and arithmetic on them is element-wise rather than concatenation.

In the real world: Scoring a million customers for churn is seconds with an array and minutes with a Python list, which decides whether a nightly job finishes.

[1, 2] + [3, 4]                  # [1, 2, 3, 4] — concatenation
np.array([1, 2]) + np.array([3, 4])   # array([4, 6]) — element-wise
np.array([1, 'a', None]).dtype        # object: a pointer array, and slow

Ending up with an object array by accident, usually from a ragged nested list, or from mixing None into numbers. It looks like an array, and every operation on it runs at Python speed with no error to tell you why.