NumPy — Thinking in Whole Arrays

You have five temperatures in Celsius and you want them in Fahrenheit. The way you were taught is a loop: take one, convert it, put it somewhere, take the next. NumPy's answer is to stop doing that — to treat the five numbers as one thing and convert all of them in a single line. That idea is the whole library, and everything below is a variation on it.

This is the first lesson in the maths series, because the others stand on it. Once arrays make sense, plotting them, solving them symbolically and running statistics on them are all short steps.

Where to type this

Everything here runs in one of two places:

  • A CodeBook cell — a notebook, one idea per cell, results underneath. Variables carry over from one Python cell to the next, so the lesson builds up as you go: an array you make near the top is still there at the bottom. That is how this page is written, and how it was tested.
  • A Python code document — one file you run top to bottom. Fine too; just keep the earlier lines around as you go, since nothing carries over between separate runs.

It runs on the device you are holding. NumPy is one of the few libraries stored with Circuitry itself rather than fetched when you first ask for it, so the first import numpy works even with no connection at all.

Every example and every block of output on this page was run before it was written, on the Python that ships with the app: Python 3.11 with numpy 1.25.

Your first array

import numpy as np

readings = np.array([12.5, 13.1, 11.8, 14.2, 13.9])
print(readings)
print(readings.shape, readings.dtype)

Run it, and the output panel below the cell shows:

[12.5 13.1 11.8 14.2 13.9]
(5,) float64

import numpy as np is the line essentially every NumPy user writes; np is the conventional short name and you will see it everywhere.

Look closely at that first line of output, because it is how you will recognise an array for the rest of your life: square brackets, and no commas. A Python list prints [12.5, 13.1]; an array prints [12.5 13.1]. The values are lined up in columns instead, which is the point — an array is a grid of numbers, not a bag of objects.

The second line is the array describing itself. shape is (5,) — five items in one dimension. dtype is float64 — every element is a 64-bit decimal number, and they are all the same type. A list can hold a number, a string and a dictionary at once. An array cannot, and that restriction is exactly what makes it fast.

Use print() to look at an array. That is the one habit worth forming early — see What the output panel shows at the end for why.

The one idea: whole arrays at once

Here is the Celsius problem both ways, in the same cell.

temps_c = [12.5, 13.1, 11.8, 14.2, 13.9]

temps_f = []
for t in temps_c:
    temps_f.append(t * 9 / 5 + 32)
print(temps_f)

temps = np.array(temps_c)
print(temps * 9 / 5 + 32)
[54.5, 55.58, 53.24, 57.56, 57.02]
[54.5  55.58 53.24 57.56 57.02]

Same numbers. Four lines became one, and that one line — temps * 9 / 5 + 32 — reads like the formula on the page of a textbook rather than like instructions to a machine. There is no counter, no empty list to prepare, no .append, and no opportunity to get the bookkeeping wrong.

This is called vectorising, and it is what people mean by "array thinking". You stop writing how to visit each number and start writing what to do to all of them.

It is also much faster, and it is worth knowing why rather than taking it on faith. The loop does the arithmetic one number at a time, in Python, asking what each object is before it can multiply it. The array version hands the whole block of numbers to compiled code that already knows they are all float64 and multiplies straight through. The difference barely shows at five numbers and is the difference between waiting and not waiting at five million — but clarity is the reason to reach for it first, and speed is the bonus.

Once you have the habit, the question you ask changes. Not "how do I loop over this?" but "what operation do I want on the whole thing?"

Making arrays

You rarely type numbers in by hand. These are the workhorses:

print(np.arange(10))
print(np.arange(0, 20, 5))
print(np.linspace(0, 1, 5))
print(np.zeros(4))
print(np.ones((2, 3)))
print(np.full(3, 7))
print(np.eye(3))
[0 1 2 3 4 5 6 7 8 9]
[ 0  5 10 15]
[0.   0.25 0.5  0.75 1.  ]
[0. 0. 0. 0.]
[[1. 1. 1.]
 [1. 1. 1.]]
[7 7 7]
[[1. 0. 0.]
 [0. 1. 0.]
 [0. 0. 1.]]
CallGives you
np.array([…])An array from a list you already have
np.arange(stop) / np.arange(start, stop, step)Whole steps, stopping before stop — like range
np.linspace(start, stop, n)n points evenly spaced, including both ends
np.zeros(n) / np.ones(n) / np.full(n, v)A block of the same value, ready to fill in
np.eye(n)The identity matrix — 1s down the diagonal

arange and linspace are easy to mix up. arange is told the step and works out how many; linspace is told how many and works out the step. For plotting a curve you almost always want linspace, because you care that it reaches the end exactly.

Pass a tuple for more than one dimension: np.ones((2, 3)) is two rows of three. Notice the extra brackets — (2, 3) is a single argument describing a shape.

Shape, and changing it

An array's shape is just how its numbers are arranged. The numbers themselves do not move when you change it.

grid = np.arange(12).reshape(3, 4)
print(grid)
print(grid.shape, grid.ndim, grid.size, grid.dtype)
print(grid.T)
print(grid.reshape(2, 6))
print(grid.ravel())
print(grid.reshape(-1, 2).shape)
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]]
(3, 4) 2 12 int32
[[ 0  4  8]
 [ 1  5  9]
 [ 2  6 10]
 [ 3  7 11]]
[[ 0  1  2  3  4  5]
 [ 6  7  8  9 10 11]]
[ 0  1  2  3  4  5  6  7  8  9 10 11]
(6, 2)

Twelve numbers, printed four different ways. reshape only rearranges — 3 × 4 and 2 × 6 and 12 are the same twelve values, so any shape whose sizes multiply to 12 is allowed and anything else is an error.

  • .shape — the arrangement, as a tuple. .ndim — how many dimensions. .size — how many numbers in total.
  • .T — the transpose: rows become columns.
  • .ravel() — flatten back to one dimension.
  • reshape(-1, 2) — the -1 means work it out. Twelve numbers, two columns, so six rows. Useful when you know the row width but not the length.

Read a shape from the outside in: (3, 4) is 3 rows of 4. A printed 2-D array helps — each inner […] is one row.

Getting things out: indexing and slicing

print(grid[0, 0], grid[2, 3], grid[-1, -1])
print(grid[1])
print(grid[:, 1])
print(grid[0:2, 1:3])
print(grid > 6)
print(grid[grid > 6])
print(grid[[0, 2]])
0 11 11
[4 5 6 7]
[1 5 9]
[[1 2]
 [5 6]]
[[False False False False]
 [False False False  True]
 [ True  True  True  True]]
[ 7  8  9 10 11]
[[ 0  1  2  3]
 [ 8  9 10 11]]

One index per dimension, separated by a comma: grid[row, column]. Negative counts from the end, so grid[-1, -1] is the last number. A bare grid[1] gives the whole of row 1. A colon means "all of this dimension", so grid[:, 1] is column 1 — the comma-and-colon form is how you take a column, and it is worth practising until it is automatic.

The two interesting ones are the last three lines.

grid > 6 is itself an array — the same shape, full of True and False. Nothing is selected yet; you have simply asked a question of every element at once. Feed that answer back in as an index, grid[grid > 6], and you get the elements where it was True. This is how filtering is done: state the condition, use it as the index. It reads almost like the sentence you would say out loud.

grid[[0, 2]] picks rows 0 and 2 by number, in the order you list them.

A slice is a view, not a copy

This one catches everybody once, and it is much easier to learn deliberately than by accident.

first_row = grid[0]
first_row[0] = 99
print(grid[0])
grid[0, 0] = 0

safe = grid[0].copy()
safe[0] = 99
print(grid[0])
[99  1  2  3]
[0 1 2 3]

Changing first_row changed grid, because a slice is a window onto the original numbers, not a duplicate of them. That is deliberate: it means slicing a large array costs nothing. When you genuinely want a separate copy, say .copy() — as the second half shows, that one leaves the original alone.

Element-wise maths

Every arithmetic operator and every standard maths function works on whole arrays.

a = np.array([1.0, 4.0, 9.0, 16.0])
print(a + 1)
print(a * 2)
print(np.sqrt(a))
print(a ** 0.5)
print(np.round(np.sin(np.linspace(0, np.pi, 5)), 3))
print(a > 5)
print((a > 5).sum())
[ 2.  5. 10. 17.]
[ 2.  8. 18. 32.]
[1. 2. 3. 4.]
[1. 2. 3. 4.]
[0.    0.707 1.    0.707 0.   ]
[False False  True  True]
2

np.sqrt takes the square root of all four numbers at once; np.sin does the same across five points from 0 to π. np.pi is there when you need it, as are np.cos, np.tan, np.exp, np.log and the rest. np.round(values, 3) rounds a whole array to three places, which is often all that stands between you and readable output.

That last pair is a small trick worth keeping. a > 5 gives True/False; True counts as 1 when you add it up, so (a > 5).sum() is "how many elements satisfy this". Counting, without a loop and without a counter.

Broadcasting: different shapes, one operation

temps * 9 worked on an array and a single number. NumPy stretched that one number across all five without you saying so. That stretching is called broadcasting, and it works between arrays too.

prices = np.array([[10.0, 20.0, 30.0],
                   [40.0, 50.0, 60.0]])
print(prices * 1.2)

discount = np.array([1.0, 0.9, 0.5])
print(prices * discount)

region = np.array([[1.0], [2.0]])
print(prices * region)

print(np.arange(3) + np.arange(3).reshape(3, 1))
[[12. 24. 36.]
 [48. 60. 72.]]
[[10. 18. 15.]
 [40. 45. 30.]]
[[ 10.  20.  30.]
 [ 80. 100. 120.]]
[[0 1 2]
 [1 2 3]
 [2 3 4]]

Four different things happened, all from the same rule:

  • A single number applied to every element.
  • A row of three applied to each of the two rows — a per-column adjustment.
  • A column of two applied across each row — a per-row adjustment.
  • A row and a column together produced a whole 3 × 3 table, which is how you build an addition or multiplication table in one line.

The rule: line the shapes up from the right, and they fit if each pair of sizes is equal or one of them is 1. The size-1 dimension is the one that gets stretched. When they do not fit, NumPy says so rather than guessing:

try:
    prices * np.array([1.0, 2.0])
except ValueError as e:
    print("ValueError:", e)
ValueError: operands could not be broadcast together with shapes (2,3) (2,)

Learn to read that message, because you will meet it often and it tells you exactly what it needs. prices is 2 rows × 3 columns; the other array has 2 elements. Lining up from the right, 3 against 2 — neither equal nor 1, so no. Two elements is a row of two where a row of three was wanted. To use it per-row, make it a column with .reshape(2, 1), as region is above.

Summing up: aggregations along an axis

sales = np.array([[120,  90,  70],
                  [200, 150, 130],
                  [ 80,  60,  40],
                  [170, 110,  95]])
print(sales.sum())
print(sales.mean())
print(sales.sum(axis=0))
print(sales.sum(axis=1))
print(sales.max(axis=0))
print(sales.min(axis=1))
print(sales.mean(axis=0).round(1))
print(sales.std().round(3))
print(sales.argmax())
print(np.unravel_index(sales.argmax(), sales.shape))
1315
109.58333333333333
[570 410 335]
[280 480 180 375]
[200 150 130]
[ 70 130  40  95]
[142.5 102.5  83.8]
45.021
3
(1, 0)

Four weeks of sales across three products. With no axis, you get one number for the whole table. With an axis, you get one number per row or per column — and which is which is the only thing to remember here:

  • axis=0 collapses the rows, leaving one value per column. [570 410 335] is the total for each product.
  • axis=1 collapses the columns, leaving one value per row. [280 480 180 375] is the total for each week.

The trick that makes it stick: axis names the dimension that disappears. sales is (4, 3); sum(axis=0) removes the 4 and leaves 3 numbers.

sum, mean, min, max, std, var, prod, any and all all take axis the same way. argmax is the odd one: it gives the position of the largest value rather than the value — but position 3 in the flattened array, which is why np.unravel_index is there to turn it back into (1, 0): row 1, column 0, the 200.

Running totals keep the shape instead of collapsing it:

print(sales.cumsum(axis=0))
[[120  90  70]
 [320 240 200]
 [400 300 240]
 [570 410 335]]

Each row is the total so far, per product — a cumulative table in one call.

Linear algebra

A = np.array([[2.0, 1.0],
              [1.0, 3.0]])
B = np.array([[1.0, 0.0],
              [0.0, 2.0]])
print(A @ B)
print(A * B)
[[2. 2.]
 [1. 6.]]
[[2. 0.]
 [0. 6.]]

@ is matrix multiplication; * is element-by-element. They are different operations that both "multiply two matrices", and mixing them up is the most common mistake in this whole area. If you want the thing you were taught in a linear algebra class — rows into columns — you want @.

Solving a system of equations is one call. For

2x +  y =  5
 x + 3y = 10
b = np.array([5.0, 10.0])
x = np.linalg.solve(A, b)
print(x)
print(A @ x)
[1. 3.]
[ 5. 10.]

So x = 1 and y = 3 — and the second line is the habit worth copying: put the answer back in and check it. A @ x returns the original right-hand side, so the solution is right.

Use solve rather than inverting the matrix. It is more accurate and faster, and it is the answer you actually wanted.

vals, vecs = np.linalg.eig(A)
print(vals)
print(vecs.round(3))
print(np.linalg.det(A))
print(np.linalg.inv(A))
print(np.linalg.norm(b))
[1.38196601 3.61803399]
[[-0.851 -0.526]
 [ 0.526 -0.851]]
5.000000000000001
[[ 0.6 -0.2]
 [-0.2  0.4]]
11.180339887498949

eig returns two things at once — the eigenvalues, then a matrix whose columns are the matching eigenvectors. det is the determinant, inv the inverse, norm the length of a vector.

That determinant deserves a word. The exact answer is 5, and you got 5.000000000000001. Nothing is broken: decimal numbers are stored in binary with finite room, so tiny residues appear in the last digit. This is ordinary floating-point arithmetic, not a NumPy quirk — it is why you compare with np.isclose(x, 5) rather than x == 5, and why .round(3) is so common in printed output.

Random numbers, and getting the same ones as this page

rng = np.random.default_rng(42)
print(rng.random(4).round(4))
print(rng.integers(1, 7, size=10))
print(rng.normal(100, 15, size=5).round(2))

deck = np.arange(1, 11)
rng.shuffle(deck)
print(deck)
print(rng.choice(["heads", "tails"], size=8))
[0.774  0.4389 0.8586 0.6974]
[2 1 4 6 5 5 5 5 4 1]
[ 87.2  113.19 111.67 100.99 116.91]
[ 7  9  2  6  5  1  8 10  3  4]
['tails' 'heads' 'heads' 'tails' 'heads' 'tails' 'tails' 'tails']

You should see exactly those numbers. That is the point of the 42. np.random.default_rng(seed) makes a generator that produces the same sequence every time from the same seed, so your run matches this page, your run tomorrow matches your run today, and anyone you share the notebook with sees what you saw. Random, but repeatable. Change the 42 and everything below it changes; drop it and you get something different every run.

  • rng.random(n) — decimals from 0 up to 1.
  • rng.integers(low, high, size=n) — whole numbers, high excluded, so 1, 7 is a dice roll.
  • rng.normal(mean, sd, size=n) — a bell curve.
  • rng.shuffle(arr) — reorders in place, changing the array you passed rather than returning a new one.
  • rng.choice(options, size=n) — picks from what you give it, including text.

Note that the calls carry on from each other: the second line does not repeat the first, it continues the same sequence. That is what makes the whole cell reproducible rather than each line separately.

Now a small experiment, which is the sort of thing arrays make trivial — roll a thousand dice and count the faces:

rolls = np.random.default_rng(7).integers(1, 7, size=1000)
print(rolls.mean().round(3))
for face in range(1, 7):
    print(face, (rolls == face).sum())
3.575
1 157
2 163
3 164
4 160
5 176
6 180

A thousand rolls in one line, a mean close to the expected 3.5, and each count done with the compare-then-sum trick from earlier. The only loop in sight runs six times — once per face — not a thousand times. That is array thinking in miniature: loop over the few things, never the many.

What the output panel shows

Two things reach the panel beneath a cell, and it is worth knowing which is which.

What you print() appears exactly as NumPy formatted it — the aligned columns, the brackets, no commas. This is what you want when you are looking at numbers, and it is why every example on this page prints.

The value the cell passes on is converted into ordinary data on its way out, and an array becomes a plain nested list:

print(np.array([[1, 2], [3, 4]]).tolist())
[[1, 2], [3, 4]]

Commas and no alignment. Nothing is lost — it is the same numbers, and it is the right form for handing on to another step — but it is harder to read. So: print what you want to look at.

Large arrays are summarised rather than dumped. Past about a thousand elements NumPy prints the ends and elides the middle:

print(np.arange(2000))
[   0    1    2 ... 1997 1998 1999]

That ... is NumPy being sensible, not the panel truncating. When you want to inspect a big array, print a slice of it (big[:10]) or a summary (big.mean(), big.shape) rather than the whole thing.

Two things that differ from what you will read elsewhere

Neither is a problem to work around, but both will confuse you for ten minutes if nobody says them out loud.

Printing a single element gives 1.5 here. Most tutorials written recently are using numpy 2.x, where printing one element out of an array shows np.float64(1.5) instead. This page runs numpy 1.25, which prints the bare number:

x = np.array([1.5, 2.5])
print(x[0])
print(f"the value is {x[0]}")
1.5
the value is 1.5

It is the same value either way — only the printed form differs. If you paste a value into a sentence or a filename and want it to read the same everywhere regardless of version, wrap it: float(x[0]) or str(round(x[0], 2)). The same difference exists between running a step on this device and running it on a connected computer, which will usually have a newer numpy — see Languages, and Where They Run.

The reverse also holds: older spellings like np.float_, np.NaN and np.infty still work here but were removed in numpy 2.x. np.nan and np.inf are the spellings that work in both, so prefer those.

Whole numbers default to 32-bit on this device. A decimal array is float64 as you would expect, but an integer array defaults to int32, where a desktop computer would give you int64. It matters in exactly one situation — very large whole numbers silently wrap around:

counts = np.array([2_000_000_000, 1_000_000])
print(counts.dtype)
print(counts + counts)
print(np.array([2_000_000_000, 1_000_000], dtype=np.int64) +
      np.array([2_000_000_000, 1_000_000], dtype=np.int64))
int32
[-294967296    2000000]
[4000000000    2000000]

Four billion does not fit in 32 bits, so the first result came back negative with no warning. Anywhere you might exceed about two billion, ask for the wider type: dtype=np.int64. Decimals are unaffected, and so is everything under that ceiling — which is most work.

Try it

Five minutes and one CodeBook.

  1. Make a new CodeBook and put import numpy as np in the first cell. Run it.
  2. In the next cell, hours = np.array([7.5, 6.0, 8.25, 7.0, 5.5]). Print it, and print hours.shape and hours.dtype.
  3. Print hours.sum(), hours.mean() and hours.max(). No loop anywhere.
  4. Print hours * 12.50 — pay at £12.50 an hour, all five days at once. Change the rate and run it again.
  5. Print hours[hours > 7] — only the long days. Then (hours > 7).sum() to count them.
  6. Make week = np.arange(1, 15).reshape(2, 7). Print it, then week.sum(axis=0) and week.sum(axis=1), and work out from the shapes which is which.
  7. In one cell, write r = np.random.default_rng(0) and then print r.integers(1, 7, size=5) twice. The two lines differ, because the generator carries on where it left off. Now run the whole cell again: both lines come back exactly as they were, because the generator was rebuilt from the same seed.

You now know: how to make an array, how to reshape it, how to pull pieces out with slices and conditions, how to do maths on all of it at once, how to summarise along an axis, how to solve a system of equations, and how to get random numbers you can reproduce. That is the foundation the rest of the maths series is built on.

Where to go next

  • The rest of this series builds directly on arrays — plotting them, working with them symbolically, and running scientific routines over them.
  • CodeBook Tour — every control in the notebook, cell by cell.
  • Code Editor — if you would rather work in a single Python file.
  • Languages, and Where They Run — what runs on the device in your hand, what runs on a connected computer, and why numbers can print differently in the two places.
  • Coming from Jupyter — how notebook habits map across.