Array Indexing, Slicing, and Reshaping

The previous article introduced NumPy arrays and why they outperform plain Python lists for numerical work. This article goes deep into numpy array indexing slicing and numpy reshape — including boolean and fancy indexing, and the view-vs-copy slicing behavior that catches nearly everyone transitioning from plain Python lists.

Building on Python List Indexing, with More Power

The starting point

Array indexing works the same basic way as accessing an element in a regular Python list — arr[0] gets the first element, exactly as you'd expect. But NumPy extends this considerably further: multidimensional access, boolean masks for filtering, and fancy indexing — none of which plain Python lists support natively at all.

What this article covers

1D and 2D indexing, slicing (including the important view-versus-copy distinction), boolean and fancy indexing, and reshaping arrays into different dimensional layouts.

Indexing: Single Elements in 1D and 2D Arrays

Basic 1D indexing
import numpy as np

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

print(arr[0])     # 10
print(arr[-1])    # 50 — negative indexing, exactly like a Python list

This works exactly like indexing a plain Python list — positive indices count from the start, negative indices count from the end.

2D indexing with comma-separated coordinates
arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

print(arr2d[0, 1])    # 2 — row 0, column 1
print(arr2d[2, -1])   # 9 — row 2, last column

Rather than the nested-bracket style you'd need for a list of lists (list2d[0][1]), NumPy lets you index a 2D array using comma-separated coordinates inside a single set of brackets: arr2d[row, column]. Both styles happen to work on NumPy arrays, but the comma-separated form is the idiomatic, NumPy-native way to express it.

A whole row vs. a single element
arr_multi = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

print(arr_multi[1])       # [4 5 6] — an entire row, returned as a 1D array
print(arr_multi[1, 0])     # 4 — a single element within that row

arr_multi[1] (a single index) returns the entire second row as its own array. arr_multi[1, 0] (two comma-separated indices) drills all the way down to a single element within that row. This distinction — one index returning a whole "slice" of the next dimension down, versus fully specifying every dimension to reach a single scalar value — is worth keeping straight, since it generalizes directly to arrays with even more dimensions.

Slicing: Extracting Sub-Arrays

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

print(arr[1:4])     # [20 30 40] — mirrors Python's own [start:end] slicing
print(arr[-3:])      # [50 60 70] — the last three elements
print(arr[::2])       # [10 30 50 70] — every second element, using a step

NumPy's slicing syntax mirrors Python's own list slicing rules exactly — start:end, an optional step, negative indices, and omitted bounds all work identically to what's covered in the earlier string and list slicing articles.

Multidimensional slicing
arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

print(arr2d[0:2, 1:3])
# [[2 3]
#  [5 6]]

print(arr2d[:, 0])
# [1 4 7] — the entire first column

arr2d[0:2, 1:3] selects rows 0–1 and columns 1–2 simultaneously — a rectangular sub-region of the matrix. arr2d[:, 0] uses a bare : for the row dimension (meaning "every row") combined with 0 for the column dimension, isolating the entire first column as its own 1D array — a genuinely common, useful pattern for pulling out one specific column from a 2D dataset.

The critical gotcha: slices are views, not copies

This is the single most important thing to internalize in this entire article, and it's genuinely easy to overlook: a NumPy slice is a view into the original array, not an independent copy. Modifying a value in the sliced result changes the original array too.

arr = np.array([10, 20, 30, 40, 50])
sub = arr[1:4]

sub[0] = 999
print(sub)   # [999  30  40]
print(arr)   # [ 10 999  30  40  50] — the original changed too!

Compare this directly to a plain Python list, where slicing always produces a genuinely independent copy:

plain_list = [10, 20, 30, 40, 50]
sub_list = plain_list[1:4]

sub_list[0] = 999
print(plain_list)   # [10, 20, 30, 40, 50] — completely unaffected

This difference is a genuine, common source of confusing bugs for anyone transitioning from plain Python lists to NumPy — code that "should" be working on an independent copy can silently corrupt the original array instead. When you genuinely need an independent copy, call .copy() explicitly:

arr = np.array([10, 20, 30, 40, 50])
sub = arr[1:4].copy()

sub[0] = 999
print(arr)   # [10 20 30 40 50] — unaffected, since sub is now a genuine, independent copy

Boolean and Fancy Indexing

Boolean indexing: filtering by condition

This is one of NumPy's single most powerful, most frequently used features. A comparison against an array produces a boolean mask — an array of True/False values, one per element — and using that mask directly as an index filters the array down to only the matching elements:

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

mask = arr > 3
print(mask)   # [False False False  True  True  True  True]

filtered = arr[mask]
print(filtered)   # [4 5 6 7]

# Or, more commonly, written in one line directly
print(arr[arr > 3])   # [4 5 6 7]

This is a genuinely idiomatic, extremely common NumPy pattern: arr[arr > 3] reads almost like plain English — "give me the elements of arr where arr is greater than 3" — and it's dramatically more concise than the equivalent Python list comprehension, [x for x in arr if x > 3], while running considerably faster on large arrays thanks to the vectorization covered in the previous article.

Fancy indexing: selecting specific, non-contiguous positions

Passing a list of specific index positions selects exactly those elements, in exactly the order you list them — genuinely different from a slice, which only selects contiguous ranges:

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

print(arr[[0, 3, 4]])   # [10 40 50] — elements at positions 0, 3, and 4, specifically
print(arr[[4, 0, 2]])   # [50 10 30] — order matters, and can even repeat indices

This is called "fancy indexing," and unlike a plain slice, it always returns a genuine copy, not a view — worth remembering, since it's the opposite of the slicing behavior covered in Section 3.

A practical combined example
data = np.array([15, 42, 8, 91, 23, 67, 4, 55])

# Filter by a condition, then select specific positions from the filtered result
above_20 = data[data > 20]
print(above_20)              # [42 91 23 67 55]
print(above_20[[0, 2, 4]])    # [42 23 55] — the 1st, 3rd, and 5th elements of the filtered result

Combining boolean filtering with fancy indexing like this is a genuinely common pattern in real data-processing code — filter a dataset down by some condition first, then select specific positions of interest from what remains.

Reshaping Arrays

The core concept

Reshaping means changing an array's shape — adding or removing dimensions, or changing how many elements sit along each existing dimension — without changing the underlying data itself. A classic example: converting a flat, 12-element 1D array into a 4×3 2D array.

arr = np.arange(12)   # [0 1 2 3 4 5 6 7 8 9 10 11]
print(arr.shape)   # (12,)

reshaped = arr.reshape(4, 3)
print(reshaped)
# [[ 0  1  2]
#  [ 3  4  5]
#  [ 6  7  8]
#  [ 9 10 11]]
print(reshaped.shape)   # (4, 3)
The requirement: total element count must stay the same

.reshape() requires the new shape to hold exactly the same total number of elements as the original array — 4 × 3 = 12, matching the original 12-element array exactly.

arr = np.arange(12)
arr.reshape(5, 3)
# ValueError: cannot reshape array of size 12 into shape (5,3)

5 × 3 = 15, which doesn't match the original 12 elements — NumPy correctly refuses, rather than silently producing something incorrect.

.flatten(): collapsing back to 1D
matrix = np.array([[1, 2, 3], [4, 5, 6]])
flat = matrix.flatten()
print(flat)   # [1 2 3 4 5 6]

.flatten() collapses a multidimensional array back down into a single, flat 1D array — genuinely useful whenever downstream code expects a simple, flat sequence rather than a structured, multidimensional shape. (Worth knowing: .flatten() always returns a genuine copy, similar to fancy indexing — if you specifically need a flattened view instead, .ravel() provides that, though the distinction rarely matters for everyday use.)

A practical use case: preparing data for machine learning

Checking .shape before feeding data into a machine learning library is a genuinely common, practical necessity — many such libraries expect input reshaped into a very specific dimensional format, and passing data with the wrong shape produces a confusing error deep inside the library's own code, rather than an immediately obvious one in your own.

# A single feature column, shape (100,) — often needs to become (100, 1) for many ML APIs
data = np.arange(100)
print(data.shape)   # (100,)

reshaped = data.reshape(-1, 1)
print(reshaped.shape)   # (100, 1)

That -1 in .reshape(-1, 1) is a genuinely useful shorthand: it tells NumPy "figure out this dimension automatically, based on the total element count and the other dimensions I've specified" — here, meaning "however many rows are needed, with exactly 1 column."

np.newaxis: adding a single new dimension

For situations where an API expects an extra dimension of length 1 specifically, np.newaxis offers a clean, explicit way to add one:

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

row_vector = arr[np.newaxis, :]
print(row_vector.shape)   # (1, 3)

column_vector = arr[:, np.newaxis]
print(column_vector.shape)   # (3, 1)

np.newaxis inserted at a given position adds a new dimension of length 1 exactly there — genuinely useful for reshaping a plain 1D array into either a row vector (1, 3) or a column vector (3, 1), depending on which axis you insert it into, a distinction that matters constantly when feeding data into libraries built around NumPy's shape conventions, exactly as covered in the earlier NumPy introduction article's mention of pandas and scikit-learn both relying on this same underlying array model.