NumPy Simulator is part of Practice Tools. Sign in or create an account, then subscribe for โฆ4,000/month to unlock it.
Step-by-step exercises for NumPy Simulator - read them here, then practice live in the tool above.
A full walkthrough of installing NumPy, understanding why arrays exist, indexing/slicing, basic array math, and writing your first small analysis script.
NumPy (Numerical Python) is the foundational array-computing library nearly every other Python data/science tool is built on top of โ pandas, scikit-learn, TensorFlow, and PyTorch all either use NumPy arrays directly or mirror its API. Its core contribution is a single data structure, the ndarray (n-dimensional array), plus a huge set of functions that operate on it far faster than equivalent pure-Python loops. The reason it's fast: a NumPy array stores all its elements as one contiguous, fixed-type block of memory and pushes loops down into pre-compiled C code, instead of looping element-by-element in Python the way a plain list does.
The beginner track gets you from "never imported numpy" to comfortably creating arrays, indexing/slicing them, and doing basic elementwise math โ the foundation everything later builds on.
python3 --version in a terminal (3.9 or newer is fine).pip install numpy (use pip3 if that's how your system distinguishes Python versions).python3 interactive shell, a Jupyter notebook (pip install notebook, then jupyter notebook), or any code editor with a Python runner works fine for this track.import numpy as np
A Python list can hold mixed types and grows/shrinks freely, but that flexibility is exactly what makes it slow for numeric work โ Python has to check each element's type individually. A NumPy array requires every element to be the same type and a fixed shape, which is what lets NumPy vectorize operations (apply them to the whole array at once, in compiled code) instead of looping in Python.
import numpy as np
# A Python list requires a manual loop or comprehension to do math on every element
prices = [10, 20, 30, 40]
doubled = [p * 2 for p in prices]
# A NumPy array does the same thing directly โ no loop needed
prices_arr = np.array([10, 20, 30, 40])
doubled_arr = prices_arr * 2 # array([20, 40, 60, 80])
prices_arr * 2 โ is called vectorization, and it's the single most important concept in this entire guide. Whenever you catch yourself writing a for loop over a NumPy array to do simple math, there is almost always a vectorized alternative that's both shorter and dramatically faster.import numpy as np
# From a Python list
a = np.array([1, 2, 3, 4, 5])
# From a nested list โ becomes a 2D array (a matrix)
b = np.array([[1, 2, 3], [4, 5, 6]])
# Built-in generators โ avoid typing values by hand
np.zeros(5) # array([0., 0., 0., 0., 0.])
np.ones((2, 3)) # a 2x3 array of 1.0
np.arange(0, 10, 2) # array([0, 2, 4, 6, 8]) โ like Python's range()
np.linspace(0, 1, 5) # 5 evenly spaced values from 0 to 1, inclusive
a = np.array([[1, 2, 3], [4, 5, 6]])
a.shape # (2, 3) โ 2 rows, 3 columns
a.ndim # 2 โ number of dimensions
a.dtype # dtype('int64') โ the single data type every element shares
a.size # 6 โ total number of elements
dtype for all its elements. Mixing an integer and a float in the same np.array([...]) call silently upcasts the whole array to float โ this is a common source of confusion for beginners who expect Python's mixed-type list behavior.a = np.array([10, 20, 30, 40, 50])
a[0] # 10 โ first element
a[-1] # 50 โ last element
a[1:4] # array([20, 30, 40]) โ slice, same rules as Python lists
a[:3] # array([10, 20, 30]) โ first three
a[::2] # array([10, 30, 50]) โ every other element
# 2D indexing โ [row, column], not [row][column]
b = np.array([[1, 2, 3], [4, 5, 6]])
b[0, 2] # 3 โ row 0, column 2
b[1, :] # array([4, 5, 6]) โ entire second row
b[:, 0] # array([1, 4]) โ entire first column
[row, column] indexing is different from a nested Python list's [row][column] syntax. Both often work, but [row, column] is the idiomatic NumPy form and is required once you get into more advanced (boolean/fancy) indexing in the intermediate track.a = np.array([1, 2, 3, 4])
b = np.array([10, 20, 30, 40])
a + b # array([11, 22, 33, 44]) โ elementwise addition
a * b # array([10, 40, 90, 160]) โ elementwise multiplication
a ** 2 # array([1, 4, 9, 16]) โ elementwise power
b / a # array([10., 10., 10., 10.]) โ elementwise division
# Aggregate functions โ reduce the whole array to one number
a.sum() # 10
a.mean() # 2.5
a.max() # 4
a.min() # 1
a.std() # standard deviation
All of the arithmetic operators above work elementwise by default โ this is different from how * behaves on two Python lists (which either errors or repeats the list, depending on the types involved). Elementwise-by-default is another core NumPy convention worth internalizing early.
np.array().fahrenheit = celsius * 9/5 + 32.celsius[celsius > celsius.mean()] โ print the result and note that this already previews the "boolean indexing" topic from the intermediate track.zeros/ones/arange/linspaceshape, dtype, and ndim[row, column] syntaxsum, mean, max, min)Rebuild the capstone script for the same problem using plain Python lists and a for loop instead of NumPy, and time both versions on a much larger array (say, 1,000,000 elements) using Python's built-in time module โ seeing the actual speed gap firsthand is more convincing than reading about it. Once array creation, indexing, and basic math feel automatic, move to the Intermediate track, which covers broadcasting, boolean/fancy indexing, and reshaping.
Where simple elementwise math stops being enough. This guide covers broadcasting, boolean and fancy indexing, reshaping, axis-aware aggregation, and basic linear algebra.
Beginner work does math between arrays of the exact same shape. Intermediate work is about NumPy's rules for combining arrays of different shapes (broadcasting), selecting elements based on a condition rather than a fixed position (boolean/fancy indexing), and reasoning about multi-dimensional arrays along a specific axis rather than just as one flat aggregate. This is also where basic linear algebra โ the actual mathematical purpose NumPy was originally built for โ enters the picture.
Broadcasting is the set of rules NumPy uses to perform elementwise operations on arrays of different shapes without you having to manually resize either one. The core rule: comparing shapes from the rightmost dimension inward, two dimensions are compatible if they're equal, or if one of them is 1.
import numpy as np
# Scalar broadcast โ this is broadcasting you already used in the beginner track
a = np.array([1, 2, 3])
a * 2 # array([2, 4, 6]) โ the scalar "stretches" to match
# 1D array broadcast against each row of a 2D array
grid = np.array([[1, 2, 3], [4, 5, 6]])
row = np.array([10, 20, 30])
grid + row
# array([[11, 22, 33],
# [14, 25, 36]]) โ row is applied to every row of grid
# Column vector broadcast against each column
col = np.array([[100], [200]]) # shape (2, 1)
grid + col
# array([[101, 102, 103],
# [204, 205, 206]])
ValueError: operands could not be broadcast together. When that happens, print both arrays' .shape first โ the fix is almost always reshaping one array (see section 4) so its dimensions align with the rule above, often using reshape(-1, 1) to turn a flat array into an explicit column vector.Beginner indexing selects elements by fixed position. Boolean indexing selects elements by condition, and fancy indexing selects them by an arbitrary list of positions โ both return new arrays rather than a single value.
a = np.array([15, 22, 8, 40, 3, 27])
# Boolean indexing โ the condition builds a True/False array of the same shape,
# then that mask selects only the True positions
mask = a > 20
mask # array([False, True, False, True, False, True])
a[mask] # array([22, 40, 27])
a[a > 20] # same thing, written inline โ the common idiom
# Modify in place using a boolean mask
a[a < 10] = 0
a # array([15, 22, 0, 40, 0, 27])
# Fancy indexing โ select by an explicit list of positions, in any order
idx = [0, 3, 5]
a[idx] # array([15, 40, 27])
# Combine conditions with & and | (not "and"/"or" โ those don't work elementwise)
a[(a > 10) & (a < 30)]
and/or keywords only work on single boolean values, not arrays โ using them on a NumPy condition raises ValueError: truth value of an array is ambiguous. Always use &/| with parentheses around each condition when combining array comparisons.a = np.arange(12) # array([0, 1, 2, ..., 11]), shape (12,)
a.reshape(3, 4) # 3 rows, 4 columns โ total elements must match (3*4=12)
a.reshape(4, 3) # 4 rows, 3 columns โ same data, different layout
a.reshape(-1, 1) # -1 means "figure this dimension out automatically"
# โ turns a flat array into an explicit column vector
grid = a.reshape(3, 4)
grid.flatten() # back to 1D, as a copy
grid.T # transpose โ swaps rows and columns, shape becomes (4, 3)
reshape and flatten don't change the underlying data โ they change how NumPy interprets the same block of memory. This is why reshaping a huge array is essentially free in terms of performance, unlike actually copying or rebuilding it.Aggregate functions like sum() and mean() default to collapsing the entire array to one number. On a multi-dimensional array, the axis parameter lets you collapse along just one dimension instead.
grid = np.array([[1, 2, 3], [4, 5, 6]]) # shape (2, 3)
grid.sum() # 21 โ every element added together
grid.sum(axis=0) # array([5, 7, 9]) โ sum DOWN each column (collapses rows)
grid.sum(axis=1) # array([6, 15]) โ sum ACROSS each row (collapses columns)
grid.mean(axis=0) # column-wise means
grid.max(axis=1) # row-wise maximums
axis=0 operates down columns (collapsing the row dimension), and axis=1 operates across rows (collapsing the column dimension) โ it names the axis being collapsed, not the direction you're moving along. Test a small example like the one above whenever you're unsure, rather than guessing.a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6], [7, 8]])
a @ b # matrix multiplication (preferred modern syntax)
np.dot(a, b) # equivalent, older syntax
a * b # NOT matrix multiplication โ this is still elementwise!
np.linalg.inv(a) # matrix inverse
np.linalg.det(a) # determinant
np.linalg.eig(a) # eigenvalues and eigenvectors, returned as a tuple
# Solving a system of linear equations Ax = b
A = np.array([[3, 1], [1, 2]])
b_vec = np.array([9, 8])
np.linalg.solve(A, b_vec) # the x that satisfies the system
* when matrix multiplication was intended. * is always elementwise on NumPy arrays โ for true matrix multiplication, use @ (or np.dot). Both are valid NumPy operations; they just answer completely different mathematical questions.axis=1, and each test's class average using axis=0..sum() on the boolean mask (True counts as 1).(4,), e.g. [0.2, 0.2, 0.3, 0.3]) against the scores and sum along axis=1 to compute a weighted final grade per student.reshape(-1, 1) and print it alongside the original grid using np.hstack (or just print both separately if you haven't covered stacking functions yet).&/|axis=0 vs. axis=1 for a given aggregation question* (elementwise) and @ (matrix multiplication)Views vs. copies, memory layout and strides, real vectorization performance work, structured arrays, and interfacing with C/Cython for the cases NumPy alone can't make fast enough.
Intermediate work treats NumPy as a black box that's simply "faster than loops." Expert work is about understanding why โ how arrays are actually laid out in memory, when an operation returns a view (sharing memory with the original) versus a copy, how to profile and eliminate the last hidden Python-level loops in your code, and when NumPy itself isn't enough and you need to drop into compiled code directly. This track assumes real fluency with everything in the intermediate guide.
This is the single most consequential thing to understand at the expert level, because getting it wrong causes silent bugs rather than errors. Basic slicing returns a view โ a new array object that shares the same underlying memory as the original. Boolean indexing, fancy indexing, and most operations that reshape or combine data return a copy.
import numpy as np
a = np.arange(10)
b = a[2:5] # a VIEW โ shares memory with a
b[0] = 999
a # array([0, 1, 999, 3, 4, 5, 6, 7, 8, 9]) โ a changed too!
c = a[[2, 3, 4]] # fancy indexing โ a COPY
c[0] = -1
a # unchanged by modifying c
# Check explicitly rather than guessing
b.base is a # True โ b is a view into a
c.base is a # False โ c owns its own data
# Force a copy when you need one, regardless of the default behavior
d = a[2:5].copy()
.copy() explicitly rather than relying on the caller to know which operations return views.A NumPy array's shape is really just a "view configuration" on top of a flat block of memory, and strides โ the number of bytes to step in memory to move one position along each axis โ are what let the same underlying data be reinterpreted as different shapes without copying.
a = np.arange(12).reshape(3, 4)
a.strides # (32, 8) on a typical 64-bit int64 array โ
# 32 bytes to move one row down, 8 bytes to move one column right
a.flags['C_CONTIGUOUS'] # True โ stored row-major (C order), the NumPy default
a.T.flags['C_CONTIGUOUS'] # False โ the transpose is a view with different strides,
# not physically reordered data
# Forcing a specific memory layout matters for performance-critical code,
# especially when passing arrays to C extensions or certain BLAS-backed operations
b = np.ascontiguousarray(a.T)
.flags['C_CONTIGUOUS'] is a reasonable next diagnostic step.The intermediate track taught you to prefer vectorized operations over explicit loops. Expert work is about finding the loops that are still hiding โ often inside a seemingly-vectorized line that's secretly falling back to slow, general-purpose code.
# Slow: np.vectorize is just a thin wrapper around a Python loop โ
# it exists for convenience/broadcasting semantics, NOT for speed
def my_func(x):
return x ** 2 if x > 0 else 0
slow = np.vectorize(my_func)(a)
# Fast: express the same logic using real vectorized primitives
fast = np.where(a > 0, a ** 2, 0)
# Slow: appending to a Python list inside a loop, converting to array at the end,
# is often fine for small N but breaks down badly at scale
results = []
for row in big_array:
results.append(row.sum())
results = np.array(results)
# Fast: axis-aware aggregation does the same thing with zero Python-level looping
results = big_array.sum(axis=1)
# %timeit inside IPython/Jupyter โ the standard first step
%timeit my_slow_function(data)
# From a plain script, use the timeit module directly
import timeit
timeit.timeit(lambda: my_slow_function(data), number=100)
np.vectorize call elsewhere accounts for 90% of it.A structured array lets a single NumPy array hold named fields of different types per "row," similar in spirit to a lightweight, fixed-schema table โ useful when you want array-level performance without pulling in a full library like pandas.
dt = np.dtype([('name', 'U10'), ('age', 'i4'), ('score', 'f8')])
people = np.array([('Ada', 32, 91.5), ('Grace', 45, 88.2)], dtype=dt)
people['name'] # array(['Ada', 'Grace'], dtype=' 90] # rows matching a condition, same boolean-mask idiom as before
For the small fraction of cases where even well-vectorized NumPy code isn't fast enough (typically tight numerical loops with data-dependent branching that can't be expressed as array operations at all), the standard escape hatches are:
@njit decorator, no separate compilation step required. Often the lowest-effort option to try first.ndarray objects at the buffer level; the most control, and the most work, reserved for library-authoring rather than typical application code.# Numba example โ often the fastest path from "slow Python loop" to "fast compiled loop"
from numba import njit
@njit
def sum_of_squares(arr):
total = 0.0
for x in arr:
total += x ** 2
return total
np.vectorize.%timeit and record the result.np.where, profile again, and record the speedup..flags['C_CONTIGUOUS'] before and after; time a .sum() on both the original and the transposed view to observe any layout-driven performance difference..copy().@njit and compare its timing against both earlier versions.np.vectorize) and replace it with a true vectorized equivalent%timeit or timeit, rather than guessing