NumPy Tutorial for Beginners: Arrays, Broadcasting and Masks
NumPy is Python's library for working with arrays of numbers: it stores them in one typed block of memory and runs maths on the whole array at once, without a Python loop. Here is the idea in four lines:
import numpy as np
marks = np.array([72, 85, 91, 64])
print(marks + 5) # [77 90 96 69]
print(marks.mean()) # 78.0
marks + 5 added 5 to every element. No for loop, no list comprehension. That single habit, writing maths on whole arrays, is most of what NumPy is. This tutorial covers the rest a beginner needs: why arrays beat lists, how to create them, shape and dtype, broadcasting, boolean masks, aggregation with axis, and the three errors everyone hits.
You can run every example on this page in the PyRun playground. Python runs in your browser, and NumPy installs itself the first time you import it, so there is nothing to set up.
Arrays vs lists: why NumPy is faster
A Python list holds pointers to separate Python objects, each carrying its own type information. A NumPy array holds raw numbers of one type packed side by side, and its operations run in compiled C code. Here is the same job, doubling a million numbers, both ways:
import sys
import timeit
import numpy as np
nums = list(range(1_000_000))
arr = np.arange(1_000_000)
t_list = timeit.timeit(lambda: [x * 2 for x in nums], number=20)
t_np = timeit.timeit(lambda: arr * 2, number=20)
print(f"list: {t_list:.3f}s numpy: {t_np:.3f}s ratio: {t_list / t_np:.0f}x")
print(arr.nbytes) # 8000000
print(sys.getsizeof(nums) + sum(sys.getsizeof(x) for x in nums)) # 36000056
On my machine the NumPy version was roughly 20 to 27 times faster across three runs. Your number will differ with your hardware, so run it yourself rather than trusting mine. Memory tells the same story: the array uses 8 MB (8 bytes per number), while the list needs about 36 MB, because each element is a full Python int object of 28 bytes plus an 8-byte pointer to it.
The trade-off: every element in an array has the same type, and the size is fixed when you create it. For lists of mixed things, keep using lists.
Creating arrays
Four functions cover most beginner code:
import numpy as np
print(np.array([1, 2, 3])) # from a list
print(np.arange(0, 10, 2)) # like range(): start, stop, step
print(np.zeros(3)) # filled with 0.0
print(np.ones((2, 3), dtype=int)) # 2 rows, 3 columns of 1
print(np.linspace(0, 1, 5)) # 5 evenly spaced points, stop included
# [1 2 3]
# [0 2 4 6 8]
# [0. 0. 0.]
# [[1 1 1]
# [1 1 1]]
# [0. 0.25 0.5 0.75 1. ]
Remember the difference between the last two families: arange takes a step and excludes the stop value, like range; linspace takes a count and includes the stop. Use linspace for floats, because floating-point steps in arange can give you one element more or fewer than you expect.
Shape, dtype and reshape
Every array has a shape (the size along each dimension) and a dtype (the type of every element).
import numpy as np
a = np.arange(12)
print(a.shape, a.ndim) # (12,) 1
m = a.reshape(3, 4)
print(m.shape, m.ndim) # (3, 4) 2
print(a.reshape(2, -1).shape) # (2, 6)
print(m.astype(float).dtype) # float64
print(m)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]
reshape needs the total to match: 12 elements can become 3 x 4 or 2 x 6, never 5 x 3. Pass -1 for one dimension and NumPy works it out. astype converts to another dtype and returns a new array.
The default integer dtype is int64 on most 64-bit machines, but it is int32 on Windows with NumPy 1.x and on 32-bit builds. Print a.dtype when it matters; it matters in the overflow section below.
Vectorised maths and broadcasting
Arithmetic between two arrays of the same shape happens element by element. This is called vectorised code:
import numpy as np
prices = np.array([100, 250, 40])
qty = np.array([3, 1, 10])
print(prices * qty) # [300 250 400]
print((prices * qty).sum()) # 950
print(prices * 1.18) # [118. 295. 47.2]
When shapes differ, NumPy tries to broadcast: it stretches the smaller array to fit, without copying data. The rule compares shapes from the right, and each pair of sizes must be equal or one of them must be 1.
import numpy as np
marks = np.array([[70, 80, 90],
[60, 75, 85]]) # shape (2, 3)
bonus = np.array([5, 0, 2]) # shape (3,) -> added to every row
print(marks + bonus)
extra = np.array([[10], [20]]) # shape (2, 1) -> added to every column
print(marks + extra)
# [[75 80 92]
# [65 75 87]]
# [[ 80 90 100]
# [ 80 95 105]]
A single number is the simplest case of broadcasting, which is why marks + 5 worked in the first example. The NumPy vectorization lesson has graded exercises on replacing loops with this style.
Boolean masks: filtering without a loop
A comparison on an array gives an array of True/False. Use that array as an index and you get only the matching elements.
import numpy as np
scores = np.array([45, 82, 67, 91, 38, 74])
mask = scores >= 60
print(mask) # [False True True True False True]
print(scores[mask]) # [82 67 91 74]
print(mask.sum()) # 4
print(scores[(scores >= 60) & (scores < 80)]) # [67 74]
print(np.where(scores >= 60, "pass", "fail"))
# ['fail' 'pass' 'pass' 'pass' 'fail' 'pass']
Two details trip people up. Combine conditions with & and |, not and and or, and put brackets around each condition because & binds tighter than >=. And mask.sum() counts the True values, a quick way to answer "how many students passed?".
Aggregation with axis
sum, mean, max, min and argmax work on the whole array by default. Pass axis to work along one dimension. axis=0 collapses the rows (one answer per column); axis=1 collapses the columns (one answer per row).
import numpy as np
# rows = 2 stores, columns = 3 days
sales = np.array([[12, 30, 18],
[25, 9, 14]])
print(sales.sum()) # 108
print(sales.sum(axis=0)) # [37 39 32] total per day
print(sales.sum(axis=1)) # [60 48] total per store
print(sales.mean(axis=1)) # [20. 16.]
print(sales.argmax(axis=0)) # [1 0 0] which store won each day
A quick check: the axis you pass is the one that disappears. sales has shape (2, 3); summing over axis=0 leaves shape (3,).
Common beginner errors
1. Shape mismatch
If the shapes cannot be broadcast, NumPy refuses:
import numpy as np
a = np.array([1, 2, 3])
b = np.array([10, 20])
print(a + b)
# ValueError: operands could not be broadcast together with shapes (3,) (2,)
Print .shape on both sides, then fix it with reshape or by selecting the right slice. Most of these errors come from a row that should have been a column, which reshape(-1, 1) fixes.
2. Silent integer overflow
Fixed-size integers have a limit. int32 tops out at 2,147,483,647, and array arithmetic that goes past it wraps around without any error:
import numpy as np
big = np.array([2_000_000_000, 5], dtype=np.int32)
print(big * 2) # [-294967296 10]
print(big.astype(np.int64) * 2) # [4000000000 10]
Plain Python integers never overflow, so this surprises people moving from lists. If you are adding up large values such as rupee totals or populations, check the dtype and use int64 or float64.
3. Views vs copies
A basic slice does not copy the data. It is a view onto the same memory, so writing to it changes the original:
import numpy as np
a = np.arange(6)
b = a[1:4] # a view
b[0] = 99
print(a) # [ 0 99 2 3 4 5]
print(np.shares_memory(a, b)) # True
c = a[1:4].copy() # an independent copy
c[0] = -1
print(a) # [ 0 99 2 3 4 5]
This is the opposite of lists, where nums[1:4] is always a new list. Boolean masks and fancy indexing like a[[0, 2]] return copies. When in doubt, call .copy().
Where to go next
Once arrays feel natural, the "advanced NumPy" topics people search for (fancy indexing, np.einsum, strides, structured arrays) are all built on the same three ideas from this page: shape, dtype and broadcasting.
The next practical step for most learners is pandas, which puts labelled rows and columns on top of NumPy arrays. Work through the NumPy intro lesson and then the pandas intro lesson; both have runnable examples and graded exercises. For quick experiments, keep the playground or the Python terminal open in another tab.