PYTHON / NUMPY
Indexing, slicing, and views
Index and slice NumPy arrays along multiple axes, and predict when the result shares memory with the original versus copies it.
What you will learn
- Index multiple axes with one tuple: a[1, 2], a[0:2, 1:3], a[..., 1:]
- Predict result shape: an integer drops an axis, a slice keeps it
- Tell views from copies with np.shares_memory and force a copy with .copy()
- Write through a view with a[:] = ... instead of rebinding the name
Understanding Indexing, slicing, and views
A NumPy array is a small header describing how to walk a flat block of memory: a pointer into a buffer, a dtype, a shape, and a strides tuple giving how many bytes to step per axis. Basic slicing never touches the buffer; it builds a new header with a shifted starting offset, a smaller shape, and possibly larger strides. That is why a[1:9:2] costs the same whether the array holds ten elements or ten million, and why the result is called a view: two headers, one buffer.
Multi-axis indexing takes a single tuple, so a[1, 2] means row 1, column 2, and a[0:2, 1:3] means rows 0-1 crossed with columns 1-2. Inside that tuple, an integer consumes an axis and removes it from the result shape, while a slice consumes an axis and keeps it with a new length. So on a (2, 3) array, g[:, 1] has shape (2,) but g[:, 1:2] has shape (2, 1), even though both select the same three... the same two numbers. Ellipsis (...) stands for as many full slices as needed, which lets you write a[..., 0] for the first element along the last axis regardless of rank.
Because views share the buffer, assignment through them is visible in the original, and that cuts both ways. block[:, :] = 0 zeroes part of the parent array, which is exactly how you edit a region in place, but it also means handing a slice to a function is handing out write access. Copies happen only when you ask for them with .copy(), or when an operation must produce new data, such as a + 1. The reliable test is np.shares_memory(x, y); do not rely on comparing .base, since a view of a view usually points its .base at the original buffer owner rather than the intermediate array.
Slices and integer indices also differ in how they fail. An out-of-range integer index raises IndexError immediately, but an out-of-range slice is clamped, so a[5:99] on a four-element array quietly yields an empty array whose emptiness only causes trouble later.
import numpy as np
a = np.arange(12).reshape(3, 4)
print(a[1, 2], a[-1, -1])
block = a[0:2, 1:3]
print(block)
print(np.shares_memory(a, block))
block[:, :] = 0
print(a)
c = a[0:2, 1:3].copy()
c[:] = 99
print(a[0, 1], c[0, 0])Basic slicing creates a new header (offset, shape, strides) over the same buffer, so slices are views that share memory while integer indices drop axes.
Worked examples
Strides make step slicing free
A strided slice and a reversed slice are views with modified strides, not rearranged data.
import numpy as np
v = np.arange(10, dtype=np.int64)
odds = v[1::2]
rev = v[::-1]
print(odds)
print(rev)
print(odds.strides, v.strides, rev.strides)
odds[0] = -1
print(v)
print(v[3:3], v[8:2].shape)Example explained
Line 1v[1::2] keeps the same buffer but doubles the stride to 16 bytes, so it hops over every other int64.
Line 2v[::-1] uses a negative stride of -8 and starts at the last element; nothing is reversed in memory.
Line 3odds[0] = -1 writes into the shared buffer, so v[1] becomes -1.
Line 4v[8:2] does not raise; both bounds are clamped into a valid but empty range of shape (0,).
Integer index versus length-one slice
Shows how each element of the index tuple decides whether an axis survives.
import numpy as np
g = np.arange(6).reshape(2, 3)
print(g[1].shape, g[1:2].shape)
print(g[:, 1].shape, g[:, 1:2].shape)
print(g[:, 1])
print(g[..., 1:].shape)
s = g[1]
s[0] = 100
print(g)Example explained
Line 1g[1] drops axis 0 and yields a 1-D row; g[1:2] keeps axis 0 with length 1, staying 2-D.
Line 2g[:, 1] gathers one entry per row into shape (2,), while g[:, 1:2] keeps the column axis as (2, 1).
Line 3g[..., 1:] expands the ellipsis to :, so it means all rows and columns 1 onward.
Line 4s is a view of row 1, so writing s[0] changes g[1, 0] to 100.
Passing a view into a function
Demonstrates that in-place writes through a view escape the function, while rebinding the parameter does not.
import numpy as np
def zero_edges(x):
x[0] = 0
x[-1] = 0
def try_replace(x):
x = np.zeros_like(x)
data = np.array([5, 6, 7, 8])
window = data[1:3]
zero_edges(window)
print(data)
try_replace(data)
print(data)
data[:] = 1
print(data)Example explained
Line 1window is a view over data[1] and data[2], so zero_edges edits the middle of data.
Line 2Inside zero_edges, x[-1] refers to the last element of the two-element view, not of data.
Line 3try_replace rebinds the local name x to a new array; data's buffer is never touched.
Line 4data[:] = 1 targets the existing buffer, which is how you replace contents in place.
Important notes
Do not test view relationships with sliced.base is parent; a view of a view usually reports the original buffer owner as its .base. Use np.shares_memory.
A view can be non-contiguous, so functions that require contiguous memory (or a .ravel() that must flatten) may copy behind the scenes; np.ascontiguousarray makes that explicit.
Common mistakes
Assuming NumPy slices copy like list slices: part = arr[0:3] then part += 1 silently mutates arr, corrupting data another part of the program still reads.
Chaining brackets for a rectangle: a[0:2][1:3] slices axis 0 twice and returns at most one row instead of the 2x2 block that a[0:2, 1:3] gives.
Trusting slices to catch bad bounds: a[10:20] on a short array returns an empty array instead of raising, so the error resurfaces far away as a shape or empty-aggregation problem.
Try it yourself
Change, predict, then run
Build a = np.arange(25).reshape(5, 5), take the central 3x3 region as a view, set its values to 0, and print a to confirm the border survived. Then repeat with .copy() and show that a is unchanged.
Open the Python workspaceCheck your understanding
With a = np.arange(6).reshape(2, 3), the code t = a[:, 1]; t += 100 changes a, but t = a[:, 1] + 100 leaves a untouched. Why?
- t is a view sharing a's buffer, so += writes into that buffer, while + allocates a new array and rebinds the name t
- += is only defined for views and + is only defined for copies, so NumPy picks different code paths
- a[:, 1] copies the column, but += reaches back to the original through the copy's .base attribute
- Column slices are views while row slices are copies, so the behaviour depends on which axis you index
Show answer
a[:, 1] is basic slicing, so t is a view over a's buffer; += calls in-place addition on that shared memory, whereas + builds a fresh array and the assignment merely points t at it. Option 3 is tempting because .base does link a view to its buffer owner, but .base is only a reference for bookkeeping and never routes writes from a copy back to the original.