Python Advanced – NumPy Arrays

August 13, 20264 min readUpdated 8/20/2026

NumPy is why Python became a data language. Its array looks like a list and behaves nothing like one, and understanding the difference is most of what you need.

An array is not a list

import numpy as np

a = np.array([1, 2, 3, 4])

print(a)             # Output: [1 2 3 4]
print(a.shape)       # Output: (4,)
print(a.dtype)       # Output: int64
print(a.nbytes)      # Output: 32

Three differences that matter. Every element has the same type, recorded once in dtype rather than on each object. The data sits in one contiguous block of memory, not scattered behind pointers. And the size is fixed — there is no append.

Those constraints are what buy the speed: four 64-bit integers in 32 bytes, against a Python list of four ints costing several hundred. It is also why the loop you would write is the wrong shape.

Vectorised operations replace the loop

import numpy as np

prices = np.array([100.0, 250.0, 40.0])

print(prices * 0.9)              # Output: [ 90. 225.  36.]
print(prices + 10)               # Output: [110. 260.  50.]
print(prices > 50)               # Output: [ True  True False]
print(prices.sum())              # Output: 390.0
print(prices.mean())             # Output: 130.0

Each operation applies to every element, with the loop running in C rather than in Python. This is the central idea: if you are writing a for loop over a NumPy array, you are usually doing it wrong.

Comparison gives you an array of booleans rather than one boolean, which is the foundation of the masking section below — and the reason if prices > 50: raises rather than doing something plausible.

How much faster

import numpy as np
import time

values = list(range(200_000))
array = np.arange(200_000)

start = time.perf_counter()
total_py = sum(v * 2 for v in values)
py = time.perf_counter() - start

start = time.perf_counter()
total_np = int((array * 2).sum())
npy = time.perf_counter() - start

print(total_py == total_np)      # Output: True
print(npy < py)                  # Output: True

Same answer, and the NumPy version is faster — typically by more than an order of magnitude at this size. The gap widens with the array.

The catch: NumPy is faster per operation on a whole array. Indexing one element from an array is slower than from a list, because it has to build a Python object out of the raw bytes. Use arrays for bulk work, not as a general-purpose container.

Shape, and reshaping

import numpy as np

grid = np.arange(12).reshape(3, 4)

print(grid.shape)         # Output: (3, 4)
print(grid.ndim)          # Output: 2
print(grid[1, 2])         # Output: 6
print(grid[:, 0])         # Output: [0 4 8]
print(grid.sum(axis=0))   # Output: [12 15 18 21]
print(grid.sum(axis=1))   # Output: [ 6 22 38]

grid[1, 2] is one index into two dimensions — not grid[1][2], which works but builds an intermediate array. grid[:, 0] takes the first column, which a list of lists cannot express at all.

axis is the one to internalise, and the way to read it is "the axis that disappears". axis=0 collapses the rows and leaves one value per column; axis=1 collapses the columns and leaves one per row.

Boolean masks

import numpy as np

amounts = np.array([100.0, -25.0, 250.0, -40.0])

mask = amounts > 0
print(mask)                       # Output: [ True False  True False]
print(amounts[mask])              # Output: [100. 250.]
print(amounts[amounts < 0].sum()) # Output: -65.0
print(np.where(amounts > 0, "credit", "debit"))
# Output: ['credit' 'debit' 'credit' 'debit']

amounts[amounts < 0] = 0
print(amounts)                    # Output: [100.   0. 250.   0.]

Indexing with a boolean array selects the elements where it is True, and assigning through one updates exactly those. That last pair of lines is a filtered update with no loop and no condition.

Combining masks needs & and |, not and and or — and each side needs brackets, because & binds tighter than >. (a > 0) & (a < 100) is correct; a > 0 and a < 100 raises.

Broadcasting

import numpy as np

grid = np.array([[1.0, 2.0], [3.0, 4.0]])
column_scale = np.array([10.0, 100.0])

print(grid * column_scale)
# Output: [[ 10. 200.]
# Output:  [ 30. 400.]]

print(grid - grid.mean(axis=0))
# Output: [[-1. -1.]
# Output:  [ 1.  1.]]

Broadcasting stretches the smaller array across the larger one without copying it. The rule is read right to left: dimensions must be equal, or one of them must be 1.

Centring every column by subtracting its mean is one expression, and it is the operation nearly every data pipeline starts with.

Views and copies

import numpy as np

original = np.array([1, 2, 3, 4])

sliced = original[:2]        # a VIEW — shares memory
sliced[0] = 99
print(original)              # Output: [99  2  3  4]

copied = original[:2].copy() # a COPY
copied[0] = 0
print(original)              # Output: [99  2  3  4]

print(sliced.base is original)   # Output: True
print(copied.base is None)       # Output: True

Slicing a list copies; slicing an array does not. A NumPy slice is a view onto the same memory, so writing through it changes the original — the opposite of the behaviour you learned in Lists.

That is deliberate and it is why slicing a gigabyte array is instant. It is also the source of the most confusing NumPy bugs. .base tells you which you are holding: not None means a view. When you need independence, say .copy().

Note that boolean indexing always copies, while plain slicing always views — so a[a > 0] is safe to modify and a[:2] is not.

Where to go next

NumPy is the layer everything else stands on. pandas is NumPy with labelled columns and is what you want for tabular data with names and mixed types; scikit-learn takes NumPy arrays directly, which is the subject of Machine Learning.

Next: Databases.