NumPy Boolean Indexing, Fancy Indexing, and np.where
Learn how to use NumPy boolean indexing, fancy indexing, and np.where to filter, select, and conditionally replace array elements, with practical examples and common pitfalls.
When working with NumPy, three related but distinct tools for selecting elements are boolean indexing, fancy indexing, and np.where. Boolean indexing uses a boolean mask to filter elements, fancy indexing uses integer arrays to pick specific positions, and np.where either returns the indices where a condition is true or chooses between two arrays based on a condition. Understanding how these approaches differ in syntax, memory behavior, and use cases helps you write efficient and correct array manipulation code.
Boolean Indexing: Selecting Elements with a Mask
Boolean indexing applies a boolean array (a mask) to select elements where the mask is True. The mask can be created directly from a condition, such as arr > 0, or from any expression that returns a boolean array.
import numpy as np arr = np.array([10, -2, 7, 0, 5]) mask = arr > 0 positive = arr[mask] print(positive) # [10 7 5]
The result is always a new array containing only the elements where the mask is True. The original array remains unchanged. This is the most common way to filter data based on a condition, and it works on arrays of any dimension.
For multidimensional arrays, a boolean mask with the same shape as the array selects individual elements and returns a 1D array of the matching elements, which can be surprising if you expect a shape-preserving result.
a = np.array([[1, 2], [3, 4]]) mask = a % 2 == 0 print(a[mask]) # [2 4]
A boolean mask can also be used along a single axis. For example, a[a[:, 0] > 5] filters rows, as long as the boolean vector matches the length of that axis.
Boolean indexing is intuitive for filtering, but it always produces a copy. If you need to modify the original array in place, assign to the indexed position: arr[mask] = 0 works because the mask is used as an index on the left side of an assignment.
Fancy Indexing: Selecting by Integer Arrays
Fancy indexing uses integer arrays (or lists) as indices to select specific rows, columns, or elements. Unlike slicing, fancy indexing returns a copy of the data, and the index arrays can be non-contiguous or even repeated.
arr = np.array([10, 20, 30, 40, 50]) indices = np.array([0, 2, 4]) selected = arr[indices] print(selected) # [10 30 50]
For multidimensional arrays, fancy indexing can select entire rows or columns by passing an integer array for one axis, or individual elements by passing index arrays for each axis. The shape of the result follows the broadcast shape of the index arrays, not the original array.
a = np.arange(12).reshape(3, 4) rows = np.array([0, 2]) cols = np.array([1, 3]) print(a[rows, cols]) # [1 11]
Here, rows and cols are paired element-wise, selecting (0,1) and (2,3). To select a block of rows, use a[rows], which returns a 2D array with the chosen rows.
Fancy indexing is powerful when you need to reorder data or pick specific positions that cannot be expressed as a slice. It is also the basis for advanced operations like shuffling rows or selecting columns based on a list of column indices.
np.where: Conditional Selection and Replacement
np.where serves two distinct purposes depending on how many arguments are passed.
One-Argument Form: Returning Indices
np.where(condition) returns a tuple of arrays containing the indices where the condition is True. For a 1D array, it returns a single-element tuple; for a 2D array, it returns row and column indices separately.
arr = np.array([5, -1, 3, -2, 8]) indices = np.where(arr > 0) print(indices) # (array([0, 2, 4]),) print(arr[indices]) # [5 3 8]
The returned indices can be used directly with fancy indexing to retrieve the matching elements. This form is useful when you need the positions themselves, for example to modify only those positions or to locate outliers.
Three-Argument Form: Choosing Between Two Arrays
np.where(condition, x, y) broadcasts condition, x, and y together and returns an array choosing elements from x where the condition is True and from y where it is False. x and y can be scalars, arrays, or broadcastable shapes.
arr = np.array([10, -2, 7, 0, 5]) result = np.where(arr > 0, arr, -1) print(result) # [10 -1 7 -1 5]
This is a concise way to replace values that fail a condition without mutating the original array. It is often used for clipping, replacing missing values, or applying a piecewise function.
Combining Boolean Masks with Logical Operators
Boolean masks become much more useful when you combine multiple conditions. NumPy provides element-wise logical operators: & for AND, | for OR, and ~ for NOT. These operators require parentheses around each condition because of Python operator precedence.
arr = np.array([1, 5, 8, 12, 15]) mask = (arr > 3) & (arr < 12) print(arr[mask]) # [5 8]
Without parentheses, the expression would be evaluated incorrectly. The and and or keywords do not work element-wise and raise a ValueError when used on arrays. Always use &, |, and ~ for element-wise logic.
You can also combine boolean indexing with fancy indexing. For example, you might first filter rows using a boolean mask and then select specific columns using an integer array.
a = np.arange(20).reshape(4, 5) row_mask = a[:, 0] > 5 filtered_rows = a[row_mask] selected_cols = filtered_rows[:, [1, 3]] print(selected_cols)
This composition is common in data preprocessing, where you filter rows based on a condition and then reorder or subset columns.
Performance and Memory Behavior
Both boolean indexing and fancy indexing return copies of the data, not views. This means they allocate new memory and can be expensive for large arrays. In contrast, slicing returns a view that shares memory with the original array. If you only need to read data, a copy is often acceptable, but if you are repeatedly filtering large datasets, the memory overhead can become significant.
np.where also returns a new array in its three-argument form. The one-argument form returns a tuple of index arrays, which are also newly allocated. When performance matters, consider whether you can avoid copying by using np.compress or by modifying in place with a mask on the left side of an assignment.
Another subtlety is that boolean indexing on a multidimensional array flattens the result to 1D. If you need to preserve shape, you might use np.where to replace values rather than filter, or reshape the result explicitly.
For the copy itself, the cost is dominated by the number of selected elements, while building the mask from a condition requires a scan of the original array. Still, if you are working with arrays that do not fit in memory, these operations will materialize the result in memory. In such cases, iterate over chunks or use an out-of-core array library rather than trying to filter the full array at once.
Common Pitfalls and Edge Cases
Several mistakes are common when using these indexing methods.
Shape Mismatch in Boolean Indexing
When a boolean mask is used to filter individual elements, it must have the same shape as the array being indexed. A boolean vector used along one axis, such as filtering rows, must match the length of that axis. If the shape is incompatible, NumPy raises an IndexError.
Using and Instead of &
As mentioned, and and or do not work element-wise. Using them raises a ValueError: The truth value of an array with more than one element is ambiguous. Always use &, |, and ~.
Fancy Indexing with Out-of-Bounds Integers
Integer indices must be within the valid range for the corresponding axis. Out-of-bounds values raise an IndexError. As with normal NumPy indexing, negative indices are allowed and count from the end, but they must still be within the range [-n, n-1].
Confusing np.where(condition) with np.where(condition, x, y)
Calling np.where(condition) returns indices, not the filtered values. If you forget the second and third arguments, you will get a tuple of arrays instead of the selected elements. This is a common source of bugs, especially when migrating from MATLAB or R.
Assignment with Fancy Indexing
When you assign to a fancy-indexed position, the assignment modifies the original array. However, if you use the same index array on both sides of the assignment, the result may differ from a loop-based assignment because fancy indexing does not guarantee order. For example, a[[0,0]] += 1 only adds 1 once, not twice. Use np.add.at if you need repeated accumulation.
Choosing Between Boolean Indexing, Fancy Indexing, and np.where
The right tool depends on what you need to do.
- Use boolean indexing when you want to filter elements based on a condition and you do not need the indices themselves. It is the most readable for simple filters.
- Use fancy indexing when you have a specific set of positions (e.g., from a list or another array) that you want to select or reorder. It is also necessary when you need to select non-contiguous slices or repeat indices.
- Use np.where(condition) when you need the indices of elements that satisfy a condition, for example to later modify those positions or to use them in a loop.
- Use np.where(condition, x, y) when you want to replace values conditionally without mutating the original array, or when you need to combine two arrays based on a condition.
In practice, these methods are often combined. A typical workflow is to build a boolean mask, use np.where to get indices for further processing, and then use fancy indexing to reorder the result. Understanding the memory and copy behavior of each helps you avoid unexpected performance bottlenecks in data-heavy applications.