One comparison, and the three things you can do with what comes back.
import numpy as np values = np.array([10, 20, 30, 40]) values >= 20 # -> [False, True, True, True] an ARRAY mask = values >= 20 values[mask] # -> [20, 30, 40] select mask.sum() # -> 3 count, since True is 1 mask.any(), mask.all() # -> (True, False) ask about the whole thing # Combining two conditions (values >= 20) & (values <= 30) # -> [False, True, True, False] (values >= 20) and (values <= 30) # ValueError: The truth value of an array with more than one element # is ambiguous. Use a.any() or a.all()
- `and` needs one answer and a mask has many
Python’s `and` has to decide whether its left side is true, which means calling bool() on the array — and there is no sensible single answer for four values, so numpy refuses. `&` is elementwise and asks the question of each position separately. The same applies to `or` versus `|` and `not` versus `~`.
- The brackets are not optional
`&` binds tighter than `>=`, so `values >= 20 & values <= 30` is parsed as `values >= (20 & values) <= 30` and gives either nonsense or an error. Every combined mask needs its comparisons in brackets, and this is the single most common numpy syntax mistake.
- Indexing with a mask copies
values[mask] cannot be described as a stride over the original — the selected positions are scattered — so numpy has to build a new array. That is the opposite of basic slicing, which returns a view, and it means writing into a masked selection never reaches the original. Useful to know in both directions.
Two comparisons, combined into one mask, then used to index the array. The operator that combines them is not `and`.
(values >= low) & (values <= high) — each comparison in its own brackets, because & binds tighter than >=. Then values[mask] selects, and the result is already a copy.
Validate the bounds before building the mask, so an inverted range is an error rather than an empty result. Nothing else is needed: no loop, no list comprehension, and no conversion on the way out.
import numpy as np def between(values, low, high): if low > high: raise ____(f’low {low} is above high {high}') mask = (values >= ____) ____ (values <= high) return values[____]
import numpy as np
def between(values, low, high):
if low > high:
raise ValueError(
f"low {low} is above high {high}, so nothing could match"
)
# & not and, and each comparison in brackets because & binds tighter.
mask = (values >= low) & (values <= high)
# Mask indexing builds a new array, so the caller can write into this.
return values[mask]
The equivalent list comprehension is no longer but it runs in Python, one value at a time, where the mask runs in compiled code over the whole array — the difference is a factor of tens on anything large. More importantly the mask is a value: you can count it, invert it with ~, combine it, or reuse it to index a different array of the same length.
The mask would match nothing and return an empty array, which is a perfectly ordinary result — so a caller who swapped two arguments would see 'no data in range' and go looking at their data. Refusing turns an invisible mistake into a visible one, which is the same judgement as every other bounds check in the catalogue.
A boolean mask selects scattered positions, and scattered positions do not form a rectangle — so a 2-D input comes back flat, in row-major order. That surprises people expecting the shape to survive. When you need the shape kept, np.where(mask, values, other) is the tool: it replaces rather than selects.
For one value, a plain comparison is clearer — masks are for arrays. For selecting rows of a table by a condition on a column, pandas' .loc and .query express the same idea with the column names visible, which matters once there are several conditions. And be careful reusing a mask against a different array: numpy only checks that the lengths match, so a mask built from last week’s data will happily index this week’s and give you a confident, meaningless selection.
- Count the matches without building the selection, and note that mask.sum() answers a different question from len(values[mask]) only in how much it allocates.
- Invert the mask with ~ to get the values outside the range, and check that the two selections together account for everything.
- Use the same mask to index a second array of the same length — labels alongside values — and work out what happens if the lengths differ by one.