Filtering without a loop: a comparison gives an array of True and False, and that array is an index.
Overview
A comparison is elementwise
Boolean masking is one of the most used features in NumPy. It lets you select, count, and modify elements based on a condition, without writing a loop. The idea is simple: a comparison produces a boolean array, and that array selects elements from the original.
The comparison step follows the same elementwise rules as arithmetic. a > 5 compares each element to 5 and returns an array of True and False values with the same shape as a. Two arrays of the same shape can be compared elementwise too. a > b gives True wherever the left element exceeds the right one.
The selection step uses the boolean array as an index. a[a > 5] returns a 1-D array containing only the elements where the condition was True. The result is always 1-D because the True positions may not form a regular grid. That is different from slicing, which preserves the shape.
Combining conditions uses the bitwise operators, not the Python keywords. The and keyword does not work on arrays because Python tries to evaluate the whole array as a single boolean, which is ambiguous. The ampersand operator works elementwise. The same applies to the pipe for or and the tilde for not. Every combined condition needs parentheses around each part because bitwise operators bind tighter than comparisons.
Boolean masks are also useful for counting. True is 1 and False is 0. mask.sum() counts the True entries. mask.mean() gives the fraction. np.any(mask) checks whether at least one element is True. np.all(mask) checks whether every element is True.
Assignment through a mask modifies only the selected elements. a[a < 0] = 0 clips negative values to zero. That is a common pattern for cleaning data in place.
This module covers comparisons, selection, combination, counting, and assignment through boolean masks. These operations appear in virtually every NumPy script and carry over directly to pandas, where boolean Series serve the same role.
a > 5 doesn't give one answer. It gives a boolean array with one entry per element.
That is the whole idea. The mask has the same shape as the data. It can be used to select from it, to count, or to decide where to write.
It also shows an error people meet early. Putting an array in an if raises. Python needs one truth value and NumPy refuses to guess whether you meant any or all.
Worth knowing
A comparison returns a boolean array, one value per element — not a single True or False.
Combine masks with &, | and ~, and bracket each condition: those operators bind tighter than the comparisons.
and/or raise, because they need one truth value and a mask has many.
mask.sum() counts and mask.mean() gives the proportion, since True is 1.
Indexing with a mask copies; assigning through one writes in place.
Comparisons against nan are always False, so filtering missing values needs np.isnan rather than !=.
Boolean Masking
A compare gives an array of True and False - and that array is an index.
A comparison gives an array
Not one answer, one per element. That array is the mask.
example_01.pyNumPy
import numpy as np
a = np.array([3, 8, 1, 9, 4, 7])
print("a :", a)
print("a > 5 :", a > 5, "<- one bool per element")
print("dtype :", (a > 5).dtype)
print()
print("using it as an index keeps the True positions:")
print("a[a > 5]:", a[a > 5])
print()
print("count and proportion come free, because True is 1:")
print(" how many :", int((a > 5).sum()))
print(" fraction :", float((a > 5).mean()))
Output
Combining conditions
Use &, | and ~ with brackets. and and or do not work, and the error explains why.
example_02.pyNumPy
import numpy as np
a = np.array([3, 8, 1, 9, 4, 7])
print("between 3 and 8 :", a[(a > 3) & (a < 8)])
print("outside that :", a[(a <= 3) | (a >= 8)])
print("not > 5 :", a[~(a > 5)])
print()
try:
a[(a > 3) and (a < 8)]
except ValueError as e:
print("using `and`:", type(e).__name__)
print(" ", str(e)[:78])
print(" `and` wants one True/False; the mask has six.")
print()
print("The brackets are required: & binds tighter than > in Python.")
Output
Use & for and, | for or, ~ for not. And bracket each rule.
The brackets aren't optional. In Python & binds more tightly than >. a > 3 & a < 8 parses as a > (3 & a) < 8 and does a thing unrelated. Writing (a > 3) & (a < 8) isn't a style preference. It's what makes the formula mean what it looks like.
and and or can't be used at all. They call bool() on their operands. This Is just the error above. The error message is unusually good. It names any() and all(). And is worth reading rather than pattern-matching past.
any, all and where
Reducing a mask to one answer, or finding the positions that are True.
example_03.pyNumPy
import numpy as np
a = np.array([3, 8, 1, 9, 4, 7])
mask = a > 5
print("mask :", mask)
print("any :", bool(mask.any()))
print("all :", bool(mask.all()))
print("count :", int(mask.sum()))
print()
idx = np.where(mask)[0]
print("np.where :", idx, "<- the INDICES where it is True")
print("a[idx] :", a[idx])
print()
print("np.where with three arguments chooses elementwise instead:")
print(" ", np.where(mask, a, 0))
Output
Assigning through a mask
The mask selects where to write. This is how you clip, clean or replace without a loop.
example_04.pyNumPy
import numpy as np
a = np.array([3.0, -1.0, 8.0, -4.0, 5.0])
print("before :", a)
a[a < 0] = 0
print("negatives→0 :", a)
a[a > 5] = 5
print("clipped at 5:", a)
print()
b = np.array([1.0, 2.0, 3.0, 4.0])
b[b % 2 == 0] *= 10
print("even *= 10 :", b)
print()
print("np.clip does the two-sided version in one call:")
print(" ", np.clip(np.array([-3.0, 2.0, 9.0]), 0, 5))
Output
a[a < 0] = 0 sets each minus element to zero, in place. The mask picks where to write.
The right-hand side can be a scalar, or an array with as many elements as the mask has True values. Boosted set works too: b[b % 2 == 0] *= 10.
This is the vectorised form of a loop with an if inside. It's often clearer than the loop and faster.
For the exact case of bounding values, np.clip(a, low, high) does both sides in one call and says what it means.
A mask index COPIES
Unlike a slice, selecting with a mask makes a new array. Writing to the result does not reach the original.
example_05.pyNumPy
import numpy as np
a = np.arange(6)
sliced = a[1:4] # a view
picked = a[a > 2] # a copy
print("slice shares memory:", np.shares_memory(a, sliced))
print("mask shares memory:", np.shares_memory(a, picked))
picked[0] = 99
print()
print("after writing to the mask result:")
print(" a :", a, "<- untouched")
print(" picked :", picked)
print()
print("But assigning THROUGH the mask does write in place:")
a[a > 2] = -1
print(" a :", a)
Output
A realistic clean-up
Masks compose, which is what makes them the normal way to filter a dataset.
a[mask] returns the elements where the mask is True, as a 1-D array.
The result is 1-D even when the input isn't. The True spots aren't often rectangular. If you need to keep the shape, np.where(mask, a, fill) replaces rather than drops.
Counting for free
A boolean array is numeric: True is 1.
So mask.sum() counts matches and mask.mean() gives the proportion. That reads oddly for about a day and then turns the obvious way to answer "how many rows meet this" without a loop or a length check.
Masking copies, assigning does not
This trips people who have just learned that slices are views.
a[mask]copies. It has to: the selected elements aren't evenly spaced. There's no stride pattern that shows them. No view is possible. Writing to the result doesn't affect the first.
a[mask] = valuewrites in place. The mask is being used to locate elements in the first array, not to build a new one.
So the same syntax reads as a copy and writes as a view. Both are correct and the gap is worth holding.
nan does not compare
The one genuine trap in filtering real data.
each compare involving nan is False. Including nan == nan. So a[a != np.nan] keeps all. a > 0 silently drops the NaNs into the False bucket whether or not you thought about them.
Use np.isnan to find them and ~np.isnan(a) to exclude them. There's a module on missing data later. For now, note that a compare will never find a NaN for you.
The shape of real filtering
Build the mask in pieces, combine it, then apply it once:
Named masks read well, compose, and can be counted and inspected before you commit to them. valid.sum() before filtering tells you how much data lives. This Is often worth knowing.
where, in its three-argument form
np.where(cond) returns the spots where the rule is true.
np.where(cond, a, b) is a thing else fully: a vectorised gated, choosing elementwise from a where the rule holds and b where it doesn't.
That second form is the array equal of a ternary formula. It replaces a great many loops. np.where(x < 0, 0, x) clamps negatives to zero. np.where(np.isnan(x), fill, x) fills missing values.
All three args broadcast, so a and b can be scalars, or arrays of a matching shape, or one of each.
The one thing to watch: both branches are checked in full. np.where(x != 0, 1/x, 0) still finds 1/x for each element, including the zeros. Emits a division warning though those results are discarded. The correct form uses out= and where= on the division itself, or suppresses the warning on purpose with errstate.
For more than two branches, np.select(rules, choices, default=...) takes a list of each and applies the first matching rule. This Is cleaner than nesting where calls three deep.
The parenthesis rule
a > 2 & a < 5 doesn't do what it looks like.
& binds tighter than > in Python. This parses as a > (2 & a) < 5. This Is a bitwise and on the values followed by a chained compare. The result is either an error or, worse, a plausible-looking wrong answer.
each rule in a compound mask needs parentheses: (a > 2) & (a < 5).
There's no way to make this less error-prone within Python's grammar. It's the single most common syntactic mistake in NumPy code. Parenthesise by reflex.
The related error is using and instead of &. Python's and needs a single truth value. An array has many. It raises "the truth value of an array with more than one element is ambiguous". That message always means the same thing.
Counting
mask.sum() counts True values, because booleans promote to ints.
np.count_nonzero(mask) does the same thing and is often faster. It doesn't build an int mid. On a large mask the gap is measurable. It's the better habit for that reason alone.
mask.any() and mask.all() short-circuit conceptually but not . NumPy tests the whole array. For an early exit on a huge array, np.argmax(mask) finds the first True in one pass and stops there.
That last trick is worth remembering: argmax on a boolean array returns the index of the first True. True is 1 and ties go to the first case. If no element is True it returns 0. This Is the same from a match at spot zero. So check mask.any() first.
Masked arrays
NumPy has a whole submodule, np.ma, for arrays that carry a mask of invalid entries alongside the data.
np.ma.masked_array(data, mask) makes an array where masked elements are excluded from each step. The mean skips them, the sum skips them. The mask passes through math.
It solves a real problem. Missing data in int arrays. Here NaN can't help — and it's truly handy in domains like climate and oceanography where it's well set.
It's also comparatively slow, less widely supported by other libraries. Easy to lose track of when a masked array passes through a function that returns a plain one. Most code handles missing values with NaN and the nan* functions, or moves to pandas, rather than adopting np.ma.
Worth knowing it exists. Worth a deliberate choice before building on it.
What masking costs
A boolean mask is one byte per element. Masking a million-element array allocates a megabyte for the mask.
a[mask] then allocates the result, whose size depends on how many elements matched. Chaining a few masked picks allocates at each step.
Two ways to reduce that when it matters.
Combine rules before selecting.a[(a > 2) & (a < 5)] allocates one result. a[a > 2][lambda r: r < 5] allocates two.
Use the mask right when you don't need the values. Counting, summing or blending matched elements can be done without extracting them: a[mask].mean() builds an mid array, while a.sum(where=mask) / np.count_nonzero(mask) doesn't.
For most work this is irrelevant and the readable form wins. It turns worth focus in a loop over large arrays. This Is just where the mid allocations compound.
Masks as first-class values
A mask is an array. This Means it can be stored, named, mixed and passed around like any other value.
That is worth using. A filtering rule assembled from a few parts is far more readable as named masks than as one long formula:
Each name docs a rule, each can be counted on its own while debugging. The mix reads as the sentence it shows.
is_adult.sum() at any point tells you how many passed that one filter. This Is how you find out which rule is unexpectedly excluding all.
Masks and NaN
checks involving NaN are all False. This Has a exact result for filtering: NaN values fail each rule.
a[a > 0] silently excludes NaNs. So does a[a <= 0]. A value that is neither greater than nor less than zero disappears from both halves of what looks like an exhaustive split.
That is at times the desired action and often a source of quietly lost rows. If NaNs should be handled rather than dropped, they need an clear branch:
positive = a > 0
missing = np.isnan(a)
other = ~positive & ~missing
Three groups that really cover all, rather than two that appear to.
Assignment through a mask, and its limits
a[a < 0] = 0 modifies in place and is the standard way to clamp.
The value assigned must be a scalar or must match the number of selected elements. a[mask] = swaps needs len(swaps) == mask.sum(). This Is fine when the swaps were found from the same mask and an error waiting to happen else.
np.where(mask, new, a) is the non-mutating equal. Returns a new array rather than modifying the first. It's the safer default in a function that shouldn't modify its arg.
np.putmask and np.copyto with a where arg cover the more specialised in-place cases.
Where masking fits among the alternatives
np.clip(a, lo, hi) replaces the common two-sided clamp and is clearer than two masked sets.
np.where handles the two-branch gated without extracting anything.
np.select handles a few branches in order.
Masking proper is for when you want the subset. The values themselves, in a smaller array. Rather than a transformed version of the whole.
Recognising which of those four you really want drops a surprising amount of code. A great many hand-written mask-and-assign sequences are a clip or a where written the long way.
And the one rule that applies throughout: parenthesise each rule in a compound mask. (a > 2) & (a < 5), always. The op precedence won't do what you want else. It may not tell you.
Questions people ask about Boolean Masking
What is a boolean mask? It is an array of True and False values with the same shape as the array you are filtering. Indexing with it keeps the elements where the mask is True.
Does a mask return a view or a copy? A copy. Reading with a mask builds a new array, but assigning through a mask, as in a[mask] = 0, writes into the original.
Why do I get an error combining conditions with and? Python's and and or need a single truth value, and an array has many. Use & and | instead, and wrap each comparison in parentheses because those operators bind tighter than comparisons.
Why is the result of a[mask] one-dimensional? The surviving elements may not form a rectangle, so NumPy flattens them. Use np.where or assign through the mask if you need the shape kept.
How do I count how many elements pass a test? Call mask.sum() or np.count_nonzero(mask). True counts as 1, so summing a boolean array counts the matches.
How do I invert a mask? Use the tilde operator, ~mask. Do not use the word not, which fails on arrays with more than one element.
A closing note on Boolean Masking
Masking is the most-used feature in this track after math. It's also where the syntax is least forgiving.
Two habits cover nearly all of it: parenthesise each rule in a compound formula. Name mid masks rather than building one long chain. The first blocks a precedence bug that doesn't always announce itself. The second makes it possible to count how many rows each rule removed. This Is how you find out that one of them is excluding all.
Check yourself
0 of 4
Answer without scrolling back up.
Why does `a[(a > 3) and (a < 8)]` raise?
Use `&`, which is elementwise. NumPy refuses to guess whether you meant any() or all(), and the error message says so.
Why are the brackets in `(a > 3) & (a < 8)` required?
Python's precedence, not NumPy's. Without brackets the expression means something unrelated to what it looks like.
Does `a[mask]` give a view or a copy?
Unlike a slice, no view can describe scattered elements. But `a[mask] = value` does write in place - the same syntax reads as a copy and writes as a view.
How do you filter out NaN values?
Every comparison with nan is False, including nan == nan, so `!=` keeps everything. Only isnan finds them.
Cheat sheet
Boolean Masking
Write a comparison on a NumPy array and use the resulting boolean array as an index. Filter, count and replace elements without any loop.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.