PYTHON / NUMPY
Boolean masking and fancy indexing
Select and modify array elements with boolean masks and integer index arrays, and know when NumPy hands back a copy instead of a view.
What you will learn
- Build masks with comparisons and combine them using &, |, ~ with parentheses
- Gather elements in any order, with repeats, using integer index arrays
- Pair row and column index arrays to pick individual elements of a 2-D array
- Recognise that advanced indexing copies, so a[mask][i] = x changes nothing
Understanding Boolean masking and fancy indexing
Boolean masking and fancy indexing are two faces of the same mechanism, advanced indexing: instead of a slice you hand NumPy an array. A boolean mask is a same-shaped array of True/False that answers a yes/no question for every element, and temps[mask] returns exactly the elements where the mask is True, in original order. An integer index array is a shopping list of positions, so temps[[0, 4, 4, 1]] returns four elements including a repeat, and the result takes its shape from the index array rather than from the source.
The behaviour that trips people up is copying. A slice like a[1:4] can be a view because the selected elements sit at a fixed stride in memory, but the True positions of a mask, or an arbitrary list of positions, are irregular, so NumPy has no choice but to allocate a fresh array and copy the values into it. Reading therefore gives you an independent array; writing still works because a[mask] = 0 is a single __setitem__ call on the original, not an assignment to the copy.
Masks compose with the bitwise operators &, |, and ~, never with and, or, and not. The Python keywords ask an array for a single truth value and NumPy refuses, raising ValueError: the truth value of an array with more than one element is ambiguous. The bitwise operators bind more tightly than comparisons, so parenthesise each condition: (x >= 60) & (y >= 60). In more than one dimension, a 1-D mask whose length matches axis 0 selects whole rows, while two integer arrays passed together are broadcast against each other and read as coordinate pairs.
import numpy as np
temps = np.array([12, 18, 21, 9, 25, 16])
hot = temps > 20
print(hot)
print(temps[hot])
picked = temps[[0, 4, 4, 1]]
print(picked)
picked[0] = 99
print(temps)
temps[temps < 10] = 10
print(temps)Advanced indexing selects arbitrary positions and therefore always returns a copy, even though assignment through the same expression still writes into the original array.
Worked examples
Selecting rows with a combined mask
Two conditions are merged with & and used to keep whole rows of a 2-D array.
import numpy as np
scores = np.array([[80, 91],
[55, 62],
[70, 99],
[40, 45]])
passing = (scores[:, 0] >= 60) & (scores[:, 1] >= 60)
print(passing)
print(scores[passing])
print(scores[[3, 0]])
print(np.flatnonzero(passing))Example explained
Line 1Each comparison produces a length-4 boolean array, and & combines them element by element.
Line 2scores[passing] works because the mask length matches axis 0, so True keeps the entire row.
Line 3scores[[3, 0]] reorders rows; integer indexing is free to repeat or reverse positions.
Line 4np.flatnonzero turns the mask into the positions 0 and 2, which is what you need when you want indices rather than values.
Coordinate pairs and conditional replacement
Two integer arrays pick scattered single elements, and np.where builds a new array from a mask.
import numpy as np
grid = np.arange(12).reshape(3, 4)
rows = np.array([0, 2, 1])
cols = np.array([3, 0, 2])
print(grid[rows, cols])
print(grid[grid % 3 == 0])
print(np.where(grid % 2 == 0, 0, grid))Example explained
Line 1grid[rows, cols] reads the index arrays in parallel: (0,3), (2,0), (1,2), giving three scalars, not a 3x4 block.
Line 2grid % 3 == 0 is a 3x4 mask, and indexing with it flattens the selection to 1-D because the True positions form no rectangle.
Line 3np.where picks from the second argument where the mask is True and from the third where it is False, leaving grid itself untouched.
In-place update versus a lost assignment
Shows that writing through a mask works, but writing into the result of a mask does not.
import numpy as np
a = np.array([5, -3, 8, -1, 0])
a[a < 0] *= -1
print(a)
a[a > 4][0] = 100
print(a)
idx = np.flatnonzero(a > 4)
a[idx[0]] = 100
print(a)Example explained
Line 1a[a < 0] *= -1 expands to a read, a multiply, and a write back through a[mask] = ..., so the negatives are flipped in the original.
Line 2a[a > 4] builds a temporary copy [5 8]; setting its element 0 changes only that temporary, which is then discarded with no error.
Line 3Converting the mask to indices first gives a real position, so a[idx[0]] = 100 lands in a itself.
Important notes
Indexing with a boolean mask always produces a 1-D result when the mask spans more than one axis, because the True elements need not form a rectangle.
np.where(mask) returns a tuple of index arrays, one per dimension, so for a 1-D array you need np.where(mask)[0] or the shorter np.flatnonzero(mask).
Common mistakes
Joining masks with and or or, as in (x > 0) and (y > 0), which raises ValueError: the truth value of an array with more than one element is ambiguous because Python asks the array for a single bool.
Chained assignment such as data[data > 10][0] = 0, which runs without any error but modifies a throwaway copy, so the original array is unchanged and the bug is silent.
Reusing a mask built from a different length or shape, giving IndexError: boolean index did not match indexed array along dimension 0; masks must be rebuilt after the array is filtered or reshaped.
Try it yourself
Change, predict, then run
Create np.arange(1, 21), then print the elements divisible by 3 but not by 2, and afterwards replace every element greater than 15 with 15 in place and print the whole array to verify the original changed.
Open the Python workspaceCheck your understanding
Why does a[a > 4][0] = 100 leave a unchanged while a[a > 4] = 100 modifies it?
- Boolean indexing returns a copy, so the first form assigns into a temporary that is discarded, while the second form is a single __setitem__ call on a itself
- The first form is invalid syntax that NumPy silently ignores
- The mask a > 4 is evaluated lazily and is still empty when the first assignment runs
- Both forms return views, but NumPy blocks chained assignment to protect the original data
Show answer
Advanced indexing cannot generally be expressed as a strided window, so a[a > 4] materialises a new array; index 0 of that new array is then overwritten and thrown away. The last option is tempting because basic slicing does return a view, and a[1:3][0] = 100 really would modify a, but a boolean mask is advanced indexing and copies. Nothing is lazy and nothing is blocked, which rules out the middle two.