Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 70 of 899 min

NumPy arrays: why one line beats a loop by a hundred times

Two ways to hold a million numbers

python
nums = [float(i) for i in range(1_000_000)]

A Python list is a row of pointers. Each pointer leads to a separate float object somewhere in memory, and each float object carries a type tag, a reference count and the value — 24 bytes for an 8-byte number. Squaring them means, for each element: follow the pointer, check that the object is a float, extract the value, compute, allocate a new float object, store the pointer. A million times.

python
import numpy as np
arr = np.arange(1_000_000, dtype=np.float64)

A NumPy array is one contiguous block of raw bytes, 8 million of them here, with a single header saying "these are float64, there are a million of them". Squaring means a compiled C loop walking that block, no type checks, no allocation per element, and the CPU's own vector instructions doing several at a time.

A row of pointers, and a block of numberslist of 1,000,000 floatsA row of pointers, eight bytes eachEach pointer leads to a separate float objectEach object carries a type tag and a referencecountAround 40 MB, scattered across memoryHolds anything: numbers, strings, other listsnp.array of 1,000,000 float64One contiguous block of 8,000,000 bytesNo per-element object at allOne header: float64, one million of them8 MB, in one place the CPU can streamHolds exactly one dtype, and that is thebargainFive times the memory is the smaller half of the story. The hundredfold speed difference is the typelookup, the method dispatch and the allocation that Python performs for every single element, and thatNumPy performs once for the whole array.
A row of pointers, and a block of numberslist of 1,000,000 floatsA row of pointers, eight bytes eachEach pointer leads to a separate floatobjectEach object carries a type tag and areference countAround 40 MB, scattered across memoryHolds anything: numbers, strings, otherlistsnp.array of 1,000,000 float64One contiguous block of 8,000,000 bytesNo per-element object at allOne header: float64, one million of them8 MB, in one place the CPU can streamHolds exactly one dtype, and that is thebargainFive times the memory is the smaller half of thestory. The hundredfold speed difference is the typelookup, the method dispatch and the allocation thatPython performs for every single element, and thatNumPy performs once for the whole array.

Measure it:

python
%timeit [x * x for x in nums]    # ~120 ms
%timeit arr ** 2                  # ~1.5 ms

That ratio is the reason NumPy exists, and the reason every numeric library in Python — pandas, scikit-learn, PyTorch's CPU tensors — is built on it or mirrors it. It is not multithreading. It is not magic. It is memory layout plus compiled loops, and understanding that tells you when it will and will not help.

Making arrays

python
np.array([1, 2, 3])                   # from a list
np.zeros(5)                           # [0., 0., 0., 0., 0.]
np.ones((2, 3))                       # 2 rows, 3 columns of 1.
np.arange(0, 10, 2)                   # [0, 2, 4, 6, 8]
np.linspace(0, 1, 5)                  # [0., 0.25, 0.5, 0.75, 1.]
np.random.default_rng(0).random(4)    # 4 floats in [0, 1), seeded

The seed matters. default_rng(0) gives the same "random" numbers every run, which is what you want when a result has to be reproducible. Unseeded, a test that passes today fails tomorrow for no reason you can find.

Every array has three attributes worth printing before you do anything with it:

python
arr.shape      # (1000000,)
arr.dtype      # float64
arr.ndim       # 1

Vectorised operations

Arithmetic applies elementwise, and so do comparisons and most maths functions:

python
a = np.array([1., 2., 3.])
b = np.array([10., 20., 30.])
a + b            # [11., 22., 33.]
a * 2            # [2., 4., 6.]
a > 1.5          # [False, True, True]
np.sqrt(a)       # elementwise square root
np.exp(a)        # elementwise e^x

np.sqrt(a) on an array is not the same as math.sqrt(a), which expects one number and raises on an array. The NumPy versions — called ufuncs — accept arrays and broadcast, which the next lesson covers.

Reductions collapse an array to a number:

python
a.sum(), a.mean(), a.max(), a.argmax(), a.std()

argmax returns the index of the largest value rather than the value — the thing you want when the array is scores and you need to know which item won.

The loop you should not write

python
total = 0
for x in arr:          # slow: pulls each element out as a Python float
    total += x

Iterating over a NumPy array in Python throws away the advantage; each element is boxed back into a Python object on the way out. If you find yourself writing for i in range(len(arr)), stop and ask what the whole-array expression is. Usually it exists: a comparison, a where, a reduction, a dot product. The lesson on masks shows the common ones.

When it genuinely does not exist — the computation depends on the previous element in a way no ufunc expresses — the honest options are numba (a free just-in-time compiler that makes such loops fast with one decorator) or accepting the loop for a small array.

When NumPy does not help

  • Small arrays. For ten numbers, the overhead of calling into C exceeds the loop. NumPy wins from a few hundred elements up.
  • Mixed types. An array holds one dtype. A column of names and prices is not an array; that is pandas' job.
  • Growing one element at a time. np.append copies the whole array each call, so building an array by appending in a loop is quadratic. Collect in a Python list, then np.array(list) once at the end.
  • Objects. np.array(["a", "b"]) works but gives you fixed-width strings or object dtype, and object arrays are Python lists in a NumPy coat — no speed-up.

Where this goes

Every embedding you fetch from a model is an array of floats, 384 to 3,072 of them. Every batch of them is a two-dimensional array. Similarity search is a matrix multiplication over that array, and the reason it runs over a hundred thousand vectors in milliseconds is exactly the layout described above. Two lessons from now you will write it.

Try this now

Build the million-element list and array, time squaring each with %timeit or timeit, and write the ratio down. Then time sum(nums) against arr.sum(), and a Python for loop over arr against arr.sum(), to see what iterating an array in Python costs.

The one thing to keep

A NumPy array is one block of memory holding values of a single type, so an operation on it is a compiled loop over raw numbers; a Python list is a row of pointers to separate objects, and the loop over it pays for type checks and allocation on every element.

Before you move on

Squaring a million numbers takes 120 ms as a Python list comprehension and 1.5 ms as `arr ** 2` on a NumPy array. A learner explains this as "NumPy uses all the CPU cores". What is the more accurate account?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly