Skip to content

Indexing & Slicing

Three ways to grab elements:

  1. Basic indexinga[2], a[1:5], a[1, 2]
  2. Fancy indexinga[[0, 2, 5]] — pass a list of indices
  3. Boolean indexinga[a > 5] — pass a True/False mask

1D — basic indexing

import numpy as np

a = np.array([10, 20, 30, 40, 50])

print(a[0])      # 10 — first
print(a[-1])     # 50 — last
print(a[1:4])    # [20, 30, 40] — slice
print(a[:3])     # first 3
print(a[::2])    # every 2nd
print(a[::-1])   # reversed

Slicing syntax is the same as Python lists.

2D — arr[row, col]

import numpy as np

a = np.array([
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12],
])

print(a[0, 0])     # 1   — top-left
print(a[2, 3])     # 12  — bottom-right
print(a[-1, -1])   # 12  — also bottom-right

# Whole row
print(a[1])        # [5, 6, 7, 8]
print(a[1, :])     # same thing

# Whole column
print(a[:, 2])     # [3, 7, 11]

2D slicing — arr[rows, cols]

import numpy as np

a = np.array([
    [ 1,  2,  3,  4],
    [ 5,  6,  7,  8],
    [ 9, 10, 11, 12],
    [13, 14, 15, 16],
])

# Top-left 2x2 block
print(a[0:2, 0:2])

# Last 2 rows, last 2 cols
print(a[-2:, -2:])

# Every other row, all cols
print(a[::2, :])

Important — slicing returns a VIEW, not a copy

import numpy as np

a = np.array([1, 2, 3, 4, 5])
b = a[1:4]      # view!

print(b)        # [2 3 4]
b[0] = 999      # modifies a too
print(a)        # [1 999 3 4 5]  ← changed!

To get an independent copy, call .copy():

import numpy as np

a = np.array([1, 2, 3, 4, 5])
b = a[1:4].copy()

b[0] = 999
print(a)        # unchanged: [1 2 3 4 5]
print(b)

Knowing whether you have a view or a copy saves bugs.

2. Fancy indexing — list of indices

Pass an array/list of integer indices to pick specific elements:

import numpy as np

a = np.array([10, 20, 30, 40, 50, 60])
print(a[[0, 2, 5]])        # [10 30 60]
print(a[[5, 0, 3]])        # [60 10 40]  — order preserved

For 2D — pass two lists, one per axis. Picks specific elements:

import numpy as np

a = np.array([
    [ 1,  2,  3],
    [ 4,  5,  6],
    [ 7,  8,  9],
    [10, 11, 12],
])

# Pick (0,0), (1,1), (2,2), (3,0)
rows = [0, 1, 2, 3]
cols = [0, 1, 2, 0]
print(a[rows, cols])      # [1 5 9 10]

For "all rows of columns 0, 2":

import numpy as np

a = np.array([
    [1, 2, 3, 4],
    [5, 6, 7, 8],
])

# Slice + fancy
print(a[:, [0, 2]])

Fancy indexing always returns a COPY (unlike slicing).

3. Boolean indexing — masks

The most powerful one. Pass a boolean array of the same shape:

import numpy as np

a = np.array([1, 5, 3, 9, 2, 8, 4])

mask = a > 4
print(mask)          # [False, True, False, True, False, True, False]
print(a[mask])       # [5 9 8]
print(a[a > 4])      # same — inline mask

You can build masks with comparison operators (>, <, ==, !=, >=, <=).

Combine masks with & (AND), | (OR), ~ (NOT) — wrap in parens:

import numpy as np

a = np.array([1, 5, 3, 9, 2, 8, 4, 7])

# Between 3 and 8 inclusive
mask = (a >= 3) & (a <= 8)
print(a[mask])        # [5 3 8 4 7]

# Odd numbers
print(a[a % 2 == 1])   # [1 5 3 9 7]

# Not equal to 5
print(a[a != 5])

Don't use and/or — those are for Python booleans, not arrays.

Modify with masks — conditional assignment

import numpy as np

a = np.array([1, 5, 3, 9, 2, 8, 4])

# Clip negatives — replace anything < 0 with 0
b = a.copy()
b[b < 0] = 0
print(b)

# Replace anything > 5 with 99
c = a.copy()
c[c > 5] = 99
print(c)

Single-element vs slice — different return types

import numpy as np

a = np.arange(10)

x = a[3]      # scalar (numpy int)
y = a[3:4]    # array of length 1

print(x, type(x).__name__)
print(y, type(y).__name__)

Often surprising — a[3:4] is an array, a[3] is a number.

... (ellipsis) — "all remaining axes"

Useful for high-dimensional arrays:

import numpy as np

a = np.ones((2, 3, 4, 5))

print(a[..., 0].shape)       # same as a[:, :, :, 0] → (2, 3, 4)
print(a[0, ...].shape)        # same as a[0, :, :, :] → (3, 4, 5)

np.newaxis — add a dimension

import numpy as np

a = np.array([1, 2, 3, 4])
print(a.shape)                       # (4,)

col = a[:, np.newaxis]
print(col)
print(col.shape)                     # (4, 1) — column vector

row = a[np.newaxis, :]
print(row)
print(row.shape)                     # (1, 4) — row vector

You can also use None: a[:, None] is the same as a[:, np.newaxis].

Putting it together — mini exercise

import numpy as np

rng = np.random.default_rng(seed=42)
scores = rng.integers(0, 100, size=15)
print("scores:", scores)

# Top 3 scores
top3 = np.sort(scores)[-3:]
print("top 3:", top3)

# Pass marks (>= 60)
print("passed:", scores[scores >= 60])

# Indices of passes
print("indices of passes:", np.where(scores >= 60)[0])

Common pitfalls

  • a[1, 2] vs a[1][2] — both work, but a[1, 2] is more efficient.
  • Slice modifications affect the original — slicing returns a view. Use .copy() if you need an independent array.
  • Using and/or on arrays — Python's and doesn't work element-wise. Use & / | and wrap conditions in parens.
  • Forgetting parens around boolean conditionsa > 3 & a < 8 is wrong because of operator precedence. Use (a > 3) & (a < 8).
  • Fancy indexing returns a COPY — assigning back doesn't always work as expected. a[[1,2,3]] = 0 does set those elements; but a[[1,2,3]] *= 2 may not when there are duplicates.

Practice

What does this print?

Expected: [20 30 40]

import numpy as np
a = np.array([10, 20, 30, 40, 50])
print(a[1:4])

Get values between 3 and 8 inclusive (you should get [3 5 8 4 7])

Expected: [3 5 8 4 7]

import numpy as np
a = np.array([1, 5, 3, 9, 2, 8, 4, 7])
print(a[a >= 3 and a <= 8])    # bug: must use & with parens for arrays

Quiz — Quick check

What you remember

Q1. Slicing a NumPy array returns a…

  • Deep copy
  • View — a window into the same underlying data
  • List
  • New array always

Why: Slicing a NumPy array gives you a view. Modifying it modifies the original. Use .copy() if you need an independent array.

Q2. Why must boolean conditions use & and |, not and and or?

  • and is slower
  • and / or only work on single booleans; & / | are element-wise
  • & is deprecated
  • and returns the wrong type

Why: a > 3 and a < 8 would try to evaluate a whole array as one boolean, raising ValueError: ambiguous truth value. (a > 3) & (a < 8) produces an element-wise mask — what you want.

Q3. For a = np.arange(10), which gives a scalar vs array?

  • a[3] → array; a[3:4] → scalar
  • a[3] → scalar; a[3:4] → array of length 1
  • Both return arrays
  • Both return scalars

Why: Integer indexing drops a dimension (scalar from 1D). Slice indexing preserves the dimension (array, even of length 1).

Common doubts

How do I know if I have a view or a copy?

Check with b.base is a — if True, b is a view of a. Rule of thumb: basic slicing returns a view; fancy indexing (lists of indices) and boolean masking return copies. When in doubt, use .copy() to guarantee independence.

Why do I need parens around (a > 3) & (a < 8)?

Python's & operator has higher precedence than < / >. Without parens, a > 3 & a < 8 is parsed as a > (3 & a) < 8 — totally wrong. Always wrap boolean conditions in parens when combining with & / |.

What's the difference between a[1, 2] and a[1][2]?

Both give the same element for 2D arrays, but a[1, 2] is faster — it's a single indexing operation. a[1][2] first creates an intermediate view of row 1, then indexes that. For high-dimensional arrays, comma-separated indexing is the idiom.

What's next

Reshaping Arrays