Intro to NumPy
NumPy ("Numerical Python") is the foundation of numerical computing in Python. Plain Python lists are flexible — they can hold anything — but that flexibility has a cost: every element is a full Python object with its own type info and memory overhead, and operations on lists run through the Python interpreter one element at a time. NumPy replaces this with the ndarray, a contiguous block of memory holding elements of a single, fixed type (like 64-bit floats), and it performs operations on entire arrays using fast, compiled C loops instead of Python-level loops.
Installing NumPy
pip install numpy
Creating arrays
There are several common ways to build an array, depending on whether you already have the data or need to generate it:
import numpy as np # From an existing list a = np.array([1, 2, 3, 4]) print(a) # [1 2 3 4] print(a.dtype) # int64 (or int32 on some platforms) # A 2D array from a list of lists grid = np.array([[1, 2, 3], [4, 5, 6]]) print(grid.shape) # (2, 3) -> 2 rows, 3 columns # Filled with zeros zeros = np.zeros((3, 3)) # A range of values, like Python's range() r = np.arange(0, 10, 2) # [0 2 4 6 8] # N evenly spaced values between two endpoints lin = np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ]
Vectorized operations vs Python loops
The single biggest reason to reach for NumPy is vectorization: writing a + b instead of looping over each pair of elements. Under the hood, NumPy dispatches the whole operation to a tight loop written in C, operating directly on contiguous memory — no per-element Python object creation, no interpreter bytecode dispatch for every addition, and on top of that the CPU can often process multiple elements per instruction (SIMD). The result is typically one to two orders of magnitude faster than the equivalent pure-Python loop.
import numpy as np
import time
n = 1_000_000
py_list = list(range(n))
np_array = np.arange(n)
# Pure Python loop
start = time.time()
py_result = [x * 2 for x in py_list]
print("Python loop:", time.time() - start)
# Vectorized NumPy
start = time.time()
np_result = np_array * 2
print("NumPy vectorized:", time.time() - start)Python loop: 0.061s NumPy vectorized: 0.002s
Shape, indexing, and slicing
Every array has a .shape (a tuple describing its dimensions) and supports slicing similar to Python lists, extended to multiple dimensions using a comma inside the brackets:
grid = np.array([
[ 1, 2, 3, 4],
[ 5, 6, 7, 8],
[ 9, 10, 11, 12],
])
print(grid.shape) # (3, 4)
print(grid[0, 0]) # 1 -> row 0, column 0
print(grid[1, :]) # [5 6 7 8] -> entire row 1
print(grid[:, 2]) # [ 3 7 11] -> entire column 2
print(grid[0:2, 1:3])
# [[2 3]
# [6 7]] -> a 2x2 sub-blockBroadcasting
Broadcasting lets NumPy apply an operation between arrays of different (but compatible) shapes without you writing an explicit loop or manually duplicating data. A smaller array is conceptually "stretched" to match the larger one's shape:
prices = np.array([10, 20, 30]) # shape (3,) discount = 0.9 # a single scalar # The scalar is broadcast across every element sale_prices = prices * discount print(sale_prices) # [ 9. 18. 27.] matrix = np.array([[1, 2, 3], [4, 5, 6]]) # shape (2, 3) row = np.array([10, 20, 30]) # shape (3,) # 'row' is broadcast across every row of 'matrix' print(matrix + row) # [[11 22 33] # [14 25 36]]
Why this matters beyond NumPy itself
NumPy arrays aren't just a convenience for math scripts — they are the shared data structure underneath most of the Python data ecosystem. pandas DataFrames store their columns as NumPy arrays, scikit-learn expects NumPy arrays as input to its models, and TensorFlow and PyTorch tensors are directly convertible to and from NumPy arrays. Learning NumPy's array model is what makes all of those libraries click.