How to Slice Arrays in NumPy

Array slicing is the bread and butter of data manipulation in NumPy. If you're doing any kind of numerical computing, machine learning, or data analysis in Python, you'll slice arrays hundreds of...

Key Insights

  • NumPy slicing uses the start:stop:step syntax but extends it to multiple dimensions with comma-separated indices, making it far more powerful than Python list slicing for matrix operations.
  • Slices return views of the original array, not copies—modifying a slice modifies the source data, which can cause subtle bugs if you’re not careful.
  • Master the ellipsis (...) operator and boolean indexing to write cleaner, more expressive code when working with high-dimensional arrays.

Introduction to NumPy Array Slicing

Array slicing is the bread and butter of data manipulation in NumPy. If you’re doing any kind of numerical computing, machine learning, or data analysis in Python, you’ll slice arrays hundreds of times per project. Understanding it deeply separates efficient NumPy code from slow, memory-hogging messes.

At its core, slicing extracts a portion of an array without explicit loops. Python lists support basic slicing, but NumPy takes it further with multi-dimensional slicing, boolean masks, and fancy indexing. The syntax looks similar, but the behavior differs in important ways.

import numpy as np

# Python list slicing
py_list = [0, 1, 2, 3, 4, 5]
print(py_list[1:4])  # [1, 2, 3]

# NumPy array slicing - same syntax, different object
np_array = np.array([0, 1, 2, 3, 4, 5])
print(np_array[1:4])  # [1 2 3]

# The key difference: NumPy supports multi-dimensional slicing
matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(matrix[0:2, 1:3])  # [[2 3], [5 6]] - rows 0-1, columns 1-2

That last example is impossible with nested Python lists using a single index operation. You’d need nested loops or list comprehensions. NumPy handles it in one clean expression.

Basic 1D Array Slicing

The fundamental syntax is arr[start:stop:step]. All three parameters are optional:

  • start: Beginning index (inclusive), defaults to 0
  • stop: Ending index (exclusive), defaults to array length
  • step: Increment between indices, defaults to 1

Negative indices count from the end. -1 is the last element, -2 is second-to-last, and so on.

arr = np.array([10, 20, 30, 40, 50, 60, 70, 80])

# Basic slices
print(arr[1:5])      # [20 30 40 50] - indices 1 through 4
print(arr[:4])       # [10 20 30 40] - start to index 3
print(arr[4:])       # [50 60 70 80] - index 4 to end
print(arr[::2])      # [10 30 50 70] - every other element
print(arr[1::2])     # [20 40 60 80] - every other, starting at index 1

# Negative indices
print(arr[-3:])      # [60 70 80] - last three elements
print(arr[:-2])      # [10 20 30 40 50 60] - all but last two
print(arr[::-1])     # [80 70 60 50 40 30 20 10] - reversed
print(arr[-2::-2])   # [70 50 30 10] - reverse, every other, from second-to-last

# Combining negative step with start/stop
print(arr[5:1:-1])   # [60 50 40 30] - indices 5 down to 2 (not 1)

The reversed array trick ([::-1]) is particularly useful and more readable than calling np.flip(). When using negative steps, remember that start should be greater than stop, or you’ll get an empty array.

Slicing 2D Arrays

Two-dimensional arrays use comma-separated indices: arr[row_slice, col_slice]. Each dimension follows the same start:stop:step rules.

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

# Select specific rows
print(matrix[0, :])      # [1 2 3 4] - first row
print(matrix[1:3, :])    # [[5 6 7 8], [9 10 11 12]] - rows 1 and 2

# Select specific columns
print(matrix[:, 0])      # [1 5 9 13] - first column
print(matrix[:, -1])     # [4 8 12 16] - last column
print(matrix[:, 1:3])    # columns 1 and 2 from all rows

# Extract submatrices
print(matrix[0:2, 0:2])  # [[1 2], [5 6]] - top-left 2x2
print(matrix[2:, 2:])    # [[11 12], [15 16]] - bottom-right 2x2

# Skip rows or columns
print(matrix[::2, ::2])  # [[1 3], [9 11]] - every other row and column

# Single colon means "all elements in this dimension"
print(matrix[:, 2])      # [3 7 11 15] - all rows, column 2

A common mistake is forgetting the comma. matrix[0] returns the first row (shape (4,)), while matrix[0, :] does the same thing explicitly. Being explicit improves readability, especially in codebases where others need to understand your intent.

Slicing Higher-Dimensional Arrays

When working with 3D+ arrays, the ellipsis (...) becomes invaluable. It expands to “as many colons as needed” to fill remaining dimensions.

# 3D array: 2 images, 3 rows, 4 columns (like batch of grayscale images)
images = np.arange(24).reshape(2, 3, 4)
print(images.shape)  # (2, 3, 4)

# Select first image
print(images[0, :, :])   # shape (3, 4)
print(images[0, ...])    # same thing, cleaner
print(images[0])         # also works for leading dimensions

# Select all images, first row of each
print(images[:, 0, :])   # shape (2, 4)
print(images[:, 0])      # equivalent

# Select last column from all images and rows
print(images[:, :, -1])  # shape (2, 3)
print(images[..., -1])   # equivalent with ellipsis

# RGB image example: shape (height, width, channels)
rgb_image = np.random.randint(0, 256, size=(100, 150, 3), dtype=np.uint8)

# Extract individual channels
red_channel = rgb_image[..., 0]    # shape (100, 150)
green_channel = rgb_image[..., 1]
blue_channel = rgb_image[..., 2]

# Crop the image (top-left 50x50 region, all channels)
cropped = rgb_image[:50, :50, :]   # shape (50, 50, 3)
cropped = rgb_image[:50, :50]      # equivalent - trailing : is optional

The ellipsis is especially useful when writing functions that should work with arrays of varying dimensionality. Instead of hardcoding the number of colons, ... adapts automatically.

Advanced Slicing Techniques

Beyond basic slicing, NumPy supports boolean indexing and fancy indexing with integer arrays. These techniques let you select non-contiguous elements based on conditions or arbitrary indices.

arr = np.array([10, 25, 30, 45, 50, 65, 70])

# Boolean indexing - select elements matching a condition
mask = arr > 30
print(mask)           # [False False False  True  True  True  True]
print(arr[mask])      # [45 50 65 70]
print(arr[arr > 30])  # same thing, inline

# Combine conditions with & (and), | (or), ~ (not)
print(arr[(arr > 20) & (arr < 60)])  # [25 30 45 50]
print(arr[(arr < 20) | (arr > 60)])  # [10 65 70]

# Fancy indexing - select by array of indices
indices = np.array([0, 2, 4])
print(arr[indices])   # [10 30 50] - elements at positions 0, 2, 4

# 2D fancy indexing
matrix = np.arange(16).reshape(4, 4)
row_indices = np.array([0, 1, 3])
col_indices = np.array([1, 2, 0])
print(matrix[row_indices, col_indices])  # [1 6 12] - (0,1), (1,2), (3,0)

# Select specific rows (not individual elements)
print(matrix[[0, 2], :])  # rows 0 and 2

# Combine slicing with fancy indexing
print(matrix[1:3, [0, 2]])  # rows 1-2, columns 0 and 2

Boolean indexing is the idiomatic way to filter arrays. It’s readable and fast because NumPy optimizes these operations in C.

Views vs Copies

This is where many developers get burned. Basic slicing returns a view—a window into the original array’s memory. Modifying the view modifies the original.

original = np.array([1, 2, 3, 4, 5])

# Slicing creates a view
view = original[1:4]
print(view)           # [2 3 4]

view[0] = 99
print(view)           # [99 3 4]
print(original)       # [1 99 3 4 5] - original changed!

# To avoid this, explicitly copy
original = np.array([1, 2, 3, 4, 5])
copy = original[1:4].copy()
copy[0] = 99
print(copy)           # [99 3 4]
print(original)       # [1 2 3 4 5] - original unchanged

# Check if array shares memory with another
print(np.shares_memory(original, original[1:4]))  # True
print(np.shares_memory(original, original[1:4].copy()))  # False

Fancy indexing and boolean indexing always return copies, not views. This inconsistency trips people up:

arr = np.array([1, 2, 3, 4, 5])

# Fancy indexing returns a copy
fancy = arr[[0, 2, 4]]
fancy[0] = 99
print(arr)  # [1 2 3 4 5] - unchanged

# Boolean indexing also returns a copy
bool_slice = arr[arr > 2]
bool_slice[0] = 99
print(arr)  # [1 2 3 4 5] - unchanged

Views are memory-efficient for large arrays since no data is duplicated. But if you need an independent array, always call .copy().

Common Pitfalls and Best Practices

Off-by-one errors: Remember that stop is exclusive. arr[0:3] gives you indices 0, 1, 2—not 0, 1, 2, 3.

Shape mismatches: When assigning slices, shapes must be compatible.

matrix = np.zeros((3, 3))

# This works - shapes match
matrix[0:2, 0:2] = np.array([[1, 2], [3, 4]])

# This fails - shape mismatch
try:
    matrix[0:2, 0:2] = np.array([1, 2, 3])  # trying to assign (3,) to (2, 2)
except ValueError as e:
    print(f"Error: {e}")
    # Error: could not broadcast input array from shape (3,) into shape (2,2)

# Debugging tip: always check shapes
slice_shape = matrix[0:2, 0:2].shape
print(f"Target shape: {slice_shape}")  # (2, 2)

Performance tips:

  1. Prefer slicing over loops—NumPy operations are vectorized in C.
  2. Use views when possible to avoid memory allocation.
  3. For very large arrays, consider memory-mapped files with np.memmap.
  4. Chain operations instead of creating intermediate arrays.
# Slow: creates intermediate arrays
temp = arr[arr > 0]
result = temp[temp < 100]

# Faster: single boolean operation
result = arr[(arr > 0) & (arr < 100)]

Slicing is fundamental to NumPy fluency. Master these patterns, understand the view/copy distinction, and you’ll write cleaner, faster numerical code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.