NumPy indexing in plain words: how to pull out exactly the values you want from an array using positions, slices, boolean masks, and fancy indexing, plus np.where, with tested examples and the view vs copy trap explained.
“Give me the right index and I will move your data. Give me the wrong one and I will move someone else’s.”
Every NumPy user, eventually
Last Updated: July 2026 | Tested on: Python 3.14.6, NumPy 2.4.6 | Difficulty: Advanced | Reading Time: 18 minutes
If creating an array is the “load the data” step, then NumPy indexing is the “ask it questions” step. Which students scored above 90? What is in column 3? Give me every other row, please. Think of an array like a long shelf of numbered lockers. Indexing is just you saying which lockers to open. The neat part is that NumPy lets you open one locker, a range of lockers, or every locker that matches a rule, all in a single line with no for loop in sight.
Here is why people care. Vinay, a 24-year-old analyst on our team, was filtering a 500,000-row dataset with a plain Python list comprehension. It took about 4 seconds every run. He rewrote it as a single NumPy boolean mask and it dropped to a few milliseconds. The speed was nice. The bigger win was that five lines of looping turned into one line you could read at a glance.
Table of Contents
Indexing Cheat Sheet
Read this picture top to bottom and you have most of the post already. There are four ways to pull values out of a NumPy array: basic indexing (one element by position), slicing (a contiguous range), boolean indexing (every element that matches a condition), and fancy indexing (elements at any positions you list). Each one hands you back either a view (it shares memory with the original array) or a copy (its own separate data), and the diagram marks which is which. Keep that view vs copy split in mind. Changing a view also changes the original array, which is handy when you mean it and a nasty surprise when you do not.
Prerequisites
Work through the NumPy array creation tutorial first. You should be comfortable making arrays and reading their shape (rows by columns). If NumPy itself is brand new to you, start one step earlier with the NumPy introduction, which explains what arrays are and why they beat plain lists. If arr.shape and np.arange already feel familiar, you are ready.
Basic Indexing and Slicing
NumPy indexing starts with the part you already know from Python lists. A single number in square brackets opens one locker. A slice with a colon opens a range. The only new thing in NumPy is the 2D version: you give a row and a column in one set of brackets, separated by a comma, like seat row and seat number at a cinema. The example below uses a small grades table for four students named Aditi, Viraj, Anvay, and Niranjan, one row per student and one column per exam.
📄 basic_indexing.py: 1D and 2D element access
import numpy as np
# 1D: works just like Python lists
temps = np.array([22.5, 24.1, 19.8, 27.3, 25.0, 21.7, 23.4])
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
print(f"Monday temp: {temps[0]}°C")
print(f"Sunday temp: {temps[-1]}°C")
print(f"Weekdays: {temps[:5]}")
print(f"Weekend: {temps[5:]}")
print(f"Every other day: {temps[::2]}")
print(f"Reversed: {temps[::-1]}")
# 2D: give it [row, column]
# 4 students, 3 exams
grades = np.array([
[85, 92, 78], # Aditi
[90, 88, 95], # Viraj
[72, 81, 69], # Anvay
[95, 97, 93] # Niranjan
])
students = ["Aditi", "Viraj", "Anvay", "Niranjan"]
print(f"\nViraj's exam 2: {grades[1, 1]}")
print(f"All exam 1 scores: {grades[:, 0]}")
print(f"Niranjan all: {grades[3, :]}")
print(f"Top 2 students, first 2 exams:\n{grades[:2, :2]}")
▶ Output
Monday temp: 22.5°C Sunday temp: 23.4°C Weekdays: [22.5 24.1 19.8 27.3 25. ] Weekend: [21.7 23.4] Every other day: [22.5 19.8 25. 23.4] Reversed: [23.4 21.7 25. 27.3 19.8 24.1 22.5] Viraj's exam 2: 88 All exam 1 scores: [85 90 72 95] Niranjan all: [95 97 93] Top 2 students, first 2 exams: [[85 92] [90 88]]
What happened here: The comma is the whole trick in 2D. grades[1, 1] reads “row 1, column 1”, which is Viraj’s second exam. A colon on its own means “all of this axis”, so grades[:, 0] says “every row, column 0”, that is exam 1 for everyone. And grades[:2, :2] grabs a small block: the first two rows and the first two columns. One small surprise to note: temps[::-1] reversed the array without copying it. That reversed result is a view, which leads straight into the part everyone gets bitten by later.
Boolean Indexing for Data Filtering
If you learn one NumPy trick for data work, make it this one. You write a condition such as prices > 100, and NumPy hands back a same-sized array of True and False values. That array is called a mask. Put the mask back inside square brackets and NumPy keeps only the elements sitting where the mask said True. Picture a long row of mailboxes and a stencil with holes punched only over the ones you want: lay the stencil down, and you see just those. The mask is the stencil.
This one idea replaces piles of if statements stuffed inside loops, and it carries straight over to boolean indexing in Pandas, where the same masks filter DataFrame rows.
📄 boolean_indexing.py: filter data without loops
import numpy as np
# Sales data: 10 products
prices = np.array([29.99, 149.99, 9.99, 79.99, 199.99,
49.99, 15.99, 299.99, 39.99, 89.99])
# Simple condition
expensive = prices > 100
print(f"Prices > $100: {prices[expensive]}")
print(f"Count: {expensive.sum()} products")
# Combining conditions (use & for AND, | for OR, ~ for NOT)
mid_range = (prices >= 30) & (prices <= 100)
print(f"\n$30-$100 range: {prices[mid_range]}")
budget = prices < 20
premium = prices > 150
budget_or_premium = budget | premium
print(f"Budget or Premium: {prices[budget_or_premium]}")
# NOT operator
not_budget = ~budget
print(f"Not budget (>=$20): {prices[not_budget]}")
# 2D boolean indexing
grades = np.array([
[85, 92, 78],
[90, 88, 95],
[72, 81, 69],
[95, 97, 93]
])
# Which individual scores are above 90?
high_scores = grades[grades > 90]
print(f"\nAll scores > 90: {high_scores}")
# Which students have average > 85?
avg = grades.mean(axis=1)
print(f"Averages: {avg}")
top_students = avg > 85
print(f"Top students (avg > 85): rows {np.where(top_students)[0]}")
print(f"Their grades:\n{grades[top_students]}")
▶ Output
Prices > $100: [149.99 199.99 299.99] Count: 3 products $30-$100 range: [79.99 49.99 39.99 89.99] Budget or Premium: [ 9.99 199.99 15.99 299.99] Not budget (>=$20): [ 29.99 149.99 79.99 199.99 49.99 299.99 39.99 89.99] All scores > 90: [92 95 95 97 93] Averages: [85. 91. 74. 95.] Top students (avg > 85): rows [1 3] Their grades: [[90 88 95] [95 97 93]]
What happened here: Each condition built a mask, and the mask picked the matching values. Two details are worth pinning down. First, the results come back in the array’s original order, not sorted. Look at Budget or Premium: you get 9.99, then 199.99, then 15.99, then 299.99, because that is the order those prices sit in the array, not smallest to largest. Second, on the 2D array, grades[top_students] used a mask of length 4 (one flag per student) to keep whole rows. A mask shaped like the rows keeps rows. A mask shaped like every cell keeps individual cells, which is what grades[grades > 90] did.
& (and), | (or), and ~ (not), and wrap each piece in parentheses: (prices >= 30) & (prices <= 100). Do not reach for Python’s and, or, not here. Those ask a whole array for a single yes or no answer, and NumPy refuses because it does not know which element you mean. The symbol versions work element by element, which is exactly what a mask needs.Fancy Indexing and np.where
Slicing only gives you contiguous ranges. Fancy indexing breaks that limit: you pass a list of the exact positions you want, in any order, and NumPy returns them in that order. It is like reading out locker numbers from a sticky note, “open 1, then 3, then 5,” instead of “open 1 through 5”.
📄 fancy_indexing.py: index with arrays of indices
import numpy as np
data = np.array([10, 20, 30, 40, 50, 60, 70, 80])
# Select specific indices
indices = np.array([1, 3, 5, 7])
print(f"Elements at {indices}: {data[indices]}")
# Reorder elements
order = np.array([7, 5, 3, 1, 0, 2, 4, 6])
print(f"Reordered: {data[order]}")
# 2D fancy indexing
matrix = np.arange(1, 17).reshape(4, 4)
print(f"\nMatrix:\n{matrix}")
# Select specific rows
rows = np.array([0, 2, 3])
print(f"Rows 0, 2, 3:\n{matrix[rows]}")
# Select specific (row, col) pairs
r = np.array([0, 1, 2, 3])
c = np.array([0, 1, 2, 3])
print(f"Diagonal via fancy index: {matrix[r, c]}")
# np.where: pick x where True, y where False
scores = np.array([45, 88, 72, 33, 91, 67, 55, 82])
result = np.where(scores >= 60, "Pass", "Fail")
print(f"\nScores: {scores}")
print(f"Result: {result}")
# np.where for replacing values
capped = np.where(scores > 80, 80, scores)
print(f"Capped at 80: {capped}")
▶ Output
Elements at [1 3 5 7]: [20 40 60 80] Reordered: [80 60 40 20 10 30 50 70] Matrix: [[ 1 2 3 4] [ 5 6 7 8] [ 9 10 11 12] [13 14 15 16]] Rows 0, 2, 3: [[ 1 2 3 4] [ 9 10 11 12] [13 14 15 16]] Diagonal via fancy index: [ 1 6 11 16] Scores: [45 88 72 33 91 67 55 82] Result: ['Fail' 'Pass' 'Pass' 'Fail' 'Pass' 'Pass' 'Fail' 'Pass'] Capped at 80: [45 80 72 33 80 67 55 80]
What happened here: Fancy indexing took a list of positions and returned those exact elements, in the order you listed them, so data[order] reshuffled the whole array. The diagonal trick matrix[r, c] pairs up the two arrays element by element: row 0 with column 0, row 1 with column 1, and so on, which walks the diagonal. Then np.where showed its two faces. np.where(scores >= 60, "Pass", "Fail") reads as “Pass where the score passes, otherwise Fail”, building a new labelled array. And np.where(scores > 80, 80, scores) is a clean way to cap values: replace anything over 80 with 80, leave everything else alone.
Views vs Copies
This is the part of NumPy indexing that bites everyone once. Some indexing gives you a view, a window onto the same data, so editing it edits the original too. Other indexing gives you a copy, your own separate data, safe to edit. Think of a shared Google Doc versus a downloaded file. Type into the shared doc and your teammate sees it instantly. Edit the download and nobody else is affected. Slicing is the shared doc. Boolean and fancy indexing are the download.
📄 views_copies.py: know when you are touching the original data
import numpy as np
# Slicing creates a VIEW (shared memory)
original = np.array([1, 2, 3, 4, 5])
view = original[1:4]
view[0] = 999
print(f"After modifying view: original = {original}")
# Original changed! [1, 999, 3, 4, 5]
# Boolean indexing creates a COPY
original2 = np.array([10, 20, 30, 40, 50])
copy = original2[original2 > 20]
copy[0] = 0
print(f"After modifying copy: original = {original2}")
# Original unchanged! [10, 20, 30, 40, 50]
# Fancy indexing creates a COPY
original3 = np.array([10, 20, 30, 40, 50])
fancy = original3[[1, 3]]
fancy[0] = 0
print(f"After modifying fancy: original = {original3}")
# Original unchanged!
# Explicit copy when you need independence
safe = original.copy()
safe[0] = -1
print(f"\nExplicit copy: safe = {safe}")
print(f"Original unchanged: {original}")
# Check if something is a view
print(f"\nview.base is original: {view.base is original}")
print(f"copy.base is original2: {copy.base is original2}")
▶ Output
After modifying view: original = [ 1 999 3 4 5] After modifying copy: original = [10 20 30 40 50] After modifying fancy: original = [10 20 30 40 50] Explicit copy: safe = [ -1 999 3 4 5] Original unchanged: [ 1 999 3 4 5] view.base is original: True copy.base is original2: False
What happened here: Editing view[0] reached back and changed original, because the slice was a view onto the same memory. Editing the boolean result and the fancy result left their originals untouched, because both are copies. The .base attribute is your proof: a view’s .base points at the array it borrows from, so view.base is original is True, while a copy’s .base is None, so copy.base is original2 is False. When you want a clean, independent array no matter how you sliced it, call .copy() and stop worrying.
View vs Copy at a Glance
Here is the one table to keep. When two methods feel confusingly similar, this is usually the difference that matters.
| Indexing method | Example | Returns | Edits original? |
|---|---|---|---|
| Basic (single element) | arr[2] | The element | n/a (a number, not an array) |
| Slicing | arr[1:4] | View | Yes |
| Boolean mask | arr[arr > 5] | Copy | No |
| Fancy (list of indices) | arr[[0, 2, 4]] | Copy | No |
| Explicit copy | arr[1:4].copy() | Copy | No |
A quick way to remember it: a plain colon slice keeps a link to the original (a view), while anything that needs NumPy to go hunting for matching or scattered elements builds fresh data (a copy). If you are ever unsure, check result.base: it is None for a copy and points at the source array for a view.
Common Mistakes
Both classic NumPy indexing mistakes come from operator precedence. Think of it like the BODMAS rule from school maths: without brackets, 2 + 3 x 4 is 14, not 20, because multiplication grabs its neighbours first. In NumPy conditions, & grabs its neighbours before > does, so missing parentheses quietly rewrite your condition into something else entirely.
📄 Mistake 1: forgetting parentheses in combined boolean conditions
import numpy as np arr = np.array([10, 20, 30, 40, 50]) # BAD: & binds tighter than >, so Python reads this as # arr > (15 & arr) < 45, a chained comparison on whole arrays # arr[arr > 15 & arr < 45] # ValueError! # GOOD: wrap each condition in its own parentheses result = arr[(arr > 15) & (arr < 45)] print(result) # [20 30 40]
📄 Mistake 2: using Python and/or instead of & and |
import numpy as np arr = np.array([1, 2, 3, 4, 5]) # BAD: "and" wants one True/False, not a whole array # arr[(arr > 2) and (arr < 5)] # ValueError! # GOOD: use & for element-wise AND print(arr[(arr > 2) & (arr < 5)]) # [3 4]
Both of these come down to one habit: in a NumPy mask, wrap each condition in parentheses and join them with &, |, or ~. The errors look scary, but the message is the same underneath: NumPy was handed a whole array where it expected a single yes or no. Parentheses and the symbol operators keep everything element by element, which is what masks are built for.
Practice Exercises
Try these by hand before you peek at the answer. Reuse the grades array from earlier: np.array([[85, 92, 78], [90, 88, 95], [72, 81, 69], [95, 97, 93]]).
- Pull a column: Get every student’s score on exam 3 (the last column) in one slice. Hint: a colon for rows, a fixed index for the column.
- Filter with a mask: Build a boolean mask that keeps only the individual scores below 80, then count how many there are with
.sum()on the mask. - Label with np.where: Take exam 1 scores (
grades[:, 0]) and turn them into an array of"Pass"/"Fail"labels using a pass mark of 75. - View vs copy: Slice the first two rows into a variable, change one value in the slice, then print the original. Was it a view or a copy? Confirm with
.base.
Conclusion
You now have the four ways to pull values out of a NumPy array and, just as important, you know which one hands you a live view and which one hands you a safe copy. Basic indexing opens one locker. Slicing opens a contiguous range and stays linked to the original. Boolean indexing filters by a rule and gives you fresh data. Fancy indexing grabs any positions you list, in any order, also as fresh data.
Add np.where for labelling and capping, remember to wrap combined conditions in parentheses with &, |, and ~, and NumPy indexing will replace whole loops with one readable line. If you take one thing away, make it the view vs copy habit: when in doubt, check result.base or call .copy().
What is next: selecting data is only half the story. Next you will learn NumPy broadcasting and vectorization, where these same arrays do arithmetic on each other without a single loop. To see where this fits in the bigger picture, browse the full Python + AI/ML tutorial series home.
Frequently Asked Questions
What is NumPy indexing?
NumPy indexing is how you pull specific values out of an array. There are four main styles: basic indexing (one element by position, like arr[2]), slicing (a range with a colon, like arr[1:4]), boolean indexing (every element matching a condition, like arr[arr > 5]), and fancy indexing (a list of positions, like arr[[0, 2, 4]]). All four work on multi-dimensional arrays and need no for loop.
What is the difference between a view and a copy in NumPy?
A view shares memory with the original array, so changing the view also changes the original. A copy has its own memory, so changes are independent. Slicing returns a view (fast and memory-light). Boolean and fancy indexing return a copy. Check result.base: it points to the source array for a view and is None for a copy.
How do I filter a NumPy array without a loop?
Write a condition to build a boolean mask, then index with it. For example, prices[prices > 100] keeps only the prices above 100. Combine conditions with & for AND, | for OR, and ~ for NOT, wrapping each condition in parentheses: prices[(prices >= 30) & (prices <= 100)]. The filtered values come back in the original array order, not sorted.
How do I filter a 2D array by row conditions?
Compute a boolean mask at the row level, such as row means, then index with it: mask = matrix.mean(axis=1) > threshold; filtered = matrix[mask]. A mask with one flag per row keeps whole rows where the condition is True. A mask shaped like every cell keeps individual elements instead.
What does np.where return?
With one argument, np.where(condition) returns a tuple of arrays holding the indices where the condition is True. With three arguments, np.where(condition, x, y) returns a new array that takes values from x where the condition is True and from y where it is False. The three-argument form is great for labelling or capping values.
Why do I get a ValueError when combining conditions in NumPy?
You probably used Python's and or or, or you forgot parentheses. NumPy needs element-wise operators: & for AND, | for OR, ~ for NOT, with each condition in its own parentheses. The error 'The truth value of an array with more than one element is ambiguous' means NumPy was handed a whole array where it expected a single True or False.
Can I use negative indices in NumPy like Python lists?
Yes. arr[-1] is the last element, arr[-2] is second to last, and arr[-3:] gives the last three elements. Negative indexing works the same way as it does for Python lists, across every dimension of the array.
Interview Questions on NumPy Indexing
Interviewers rarely ask for definitions. They ask what happens in situations like these.
Q: What is the difference between a view and a copy, and how do you tell which one you got?
A view shares memory with the original array, so writing to it changes the original; a copy owns its data, so edits stay local. Slicing returns a view, while boolean and fancy indexing return a copy. To check at runtime, inspect result.base: it points at the source array for a view and is None for a copy. When you need guaranteed independence, call .copy().
Q: Why does arr[arr > 2 & arr < 5] raise a ValueError, and how do you fix it?
The & operator binds tighter than the comparison operators, so Python evaluates 2 & arr first and then tries a chained comparison on whole arrays, which is ambiguous. Wrap each condition in its own parentheses: arr[(arr > 2) & (arr < 5)]. The same rule applies to | for OR and ~ for NOT.
Q: How is fancy indexing different from slicing, and when would you reach for it?
Slicing can only select a contiguous range with a fixed step, and it returns a view. Fancy indexing takes a list or array of explicit positions in any order, so you can pick indices 5, 0, and 3 or reorder an entire array, and it always returns a copy. Use fancy indexing when the positions you want are scattered or need a custom order that a slice cannot express.
Q: What are the two forms of np.where and what does each return?
With one argument, np.where(condition) returns a tuple of index arrays marking where the condition is True, which is handy for finding positions. With three arguments, np.where(condition, x, y) returns a new array that takes values from x where the condition holds and from y otherwise. The three-argument form is the go-to for labelling (Pass/Fail) or capping values.
Q: Scenario: a colleague named Anvi slices a large array, edits the slice to normalize a few values, and reports that the original array mysteriously changed. What happened and how do you fix it?
Her slice is a view, so it shares memory with the original and her edits wrote straight through. This is expected NumPy behaviour, not a bug. If she wants an independent array to modify, she should slice and then call .copy(), for example chunk = big[10:20].copy(). She can confirm the fix by checking that chunk.base is None is True.
Q: Scenario: you filter a 500,000-row array with a boolean mask and memory usage jumps sharply. What is going on and what would you check first?
Boolean indexing returns a copy, so filtering a large array allocates a brand new array holding all the matching elements, which explains the memory spike. First check how selective the mask is: if it keeps most rows, the copy is nearly as large as the source. If you only need to read or aggregate, operate on the mask directly (for example arr[mask].sum() or reduce with np.count_nonzero(mask)) instead of materializing the full filtered array, or process the data in chunks.
Q: How do you select whole rows of a 2D array versus individual cells using a boolean mask?
A mask whose length matches the number of rows keeps entire rows where it is True, so matrix[matrix.mean(axis=1) > 85] returns the qualifying rows. A mask shaped like the full array (one flag per cell) keeps individual elements and flattens them into a 1D result, which is what matrix[matrix > 90] does. The shape of the mask decides whether you get rows or scattered cells.
Q: In a NumPy mask, why can you not use Python's and, or, and not?
Python's and, or, and not try to reduce their operands to a single boolean, but a NumPy array with more than one element has no single truth value, so NumPy raises "The truth value of an array with more than one element is ambiguous." The element-wise operators &, |, and ~ compare each element independently and produce a mask, which is exactly what indexing needs.
Go deeper: when you outgrow this post, NumPy official documentation is the next stop.
Related Posts
Previous: NumPy: Array Creation with zeros, ones, arange, linspace
Next: NumPy: Broadcasting, Vectorization, ufuncs
Series Home: Python + AI/ML Tutorial Series

No comment