Indexing and Slicing
Indexing into arrays is how data gets out, and it comes in three flavors with different performance and copy semantics: basic indexing (integers and slices), fancy indexing (integer arrays), and boolean masking. The flavor matters - basic slicing returns a view (writes pass through), while fancy indexing and boolean masks return copies (writes do not). Mastering this chapter is mastering the single most-used part of the API.
Basic Indexing: 1-D
import numpy as np
a = np.arange(10) # [0 1 2 3 4 5 6 7 8 9]
print(a[0], a[3]) # 0 3
print(a[-1]) # 9 - negative indexes count from the end
print(a[2:5]) # [2 3 4] - start:stop, end exclusive
print(a[::2]) # [0 2 4 6 8] - step of 2
print(a[7:]) # [7 8 9] - open end
print(a[:]) # the whole array (a view)
print(a[::-1]) # reversed - a view, no copy
Slice syntax [start:stop:step] is Python's, with open ends allowed. One critical property: basic slicing returns a view - a window over the same memory. Mutating the slice mutates the original:
b = a[2:5]
b[0] = 99
print(a[2]) # 99 - the original changed
Basic Indexing: 2-D and Beyond
m = np.arange(12).reshape(3, 4)
print(m)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]
print(m[1]) # row 1: [4 5 6 7]
print(m[1, 2]) # element: 6
print(m[0, 1:3]) # row 0, columns 1-2: [1 2]
print(m[:, 1]) # all rows, column 1: [1 5 9]
print(m[1:, 2:]) # rows 1+, cols 2+: [[6 7] [10 11]]
print(m[..., 0]) # ellipsis: all leading axes, column 0
One index expression per axis, in axis order; : means "all of this axis"; the ... (ellipsis) abbreviates the remaining leading axes and is the clean way to index the last axis of an array of unknown dimension. Every basic expression above returns a view when it is a pure slice - the m[1:, 2:] block shares memory with m.
Fancy Indexing (Integer Arrays)
a = np.arange(10)
print(a[[0, 2, 4]]) # [0 2 4] - pick specific positions
print(a[np.array([1, 3, 5])]) # same, via an explicit array
m = np.arange(12).reshape(3, 4)
print(m[[0, 2]]) # rows 0 and 2 (a copy)
print(m[[0, 1], 2]) # (row 0, col 2) and (row 1, col 2): [2 6]
# paired indexing: one row index per column index
rows = np.array([0, 1, 2])
cols = np.array([3, 1, 0])
print(m[rows, cols]) # [3 5 8] - element at (0,3), (1,1), (2,0)
Passing an integer array selects positions; the result is a copy, so assignments do not write back: a[[0, 2]][0] = 99 changes nothing in a (to assign, use a[[0, 2]] = [99, 98] in one step). The paired form m[rows, cols] is the standard "gather these cells" operation - one result per (row, col) pair - and it is how scatter/gather, sampling, and permutation patterns are written.
Boolean Masking
a = np.arange(10)
print(a[a > 5]) # [6 7 8 9] - elements passing the mask
print(a[a % 2 == 0]) # [0 2 4 6 8]
m = np.arange(12).reshape(3, 4)
big = m > 5 # a boolean array, same shape
print(m[big]) # all elements > 5, flattened: [6 7 9 10 11]
print(big.sum()) # 5 - counting is just a mask sum
# assignment through a mask - the workhorse mutation pattern
a[a < 5] = 0
print(a) # [0 0 0 0 0 5 6 7 8 9]
A boolean array of the same shape acts as a mask: arr[mask] returns the True positions as a 1-D copy, and arr[mask] = value writes through to the original - the conditional-assignment idiom that replaces loops. Compound conditions use &, |, ~ with parentheses (operators bind tighter than the bitwise forms, and Python's and does not work elementwise).
The View/Copy Rule, Summarized
| Operation | Returns | Writes pass through? |
|---|---|---|
pure slice a[1:5], m[1:, 2:] | view | yes |
integer/fancy a[[1,3]], m[rows, cols] | copy | no (use one-step assignment) |
boolean mask a[a > 5] | copy | assignment form writes through |
When a view surprises you (a change you did not make), the diagnosis is x.base is not None or a np.shares_memory(x, y) check; when a copy surprises you, the fix is .copy() explicitly. The rule above covers 99% of cases - learn it as a reflex.
Summary
Index with one expression per axis: slices for ranges (views, writes pass through), integer arrays for picked positions (copies), boolean masks for conditional selection and the arr[mask] = value mutation idiom, and the ellipsis for the trailing axis of open-dimensional code. Keep the view/copy rule in muscle memory - it explains more NumPy "mystery changes" than any other fact - and use np.shares_memory when the memory relationship itself needs confirming.