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.