Getting at elements, rows and rectangles - and the one behaviour that differs from lists in a way that matters.
Overview
Slices, per axis
The familiar start:stop:step, once per dimension.
a[1:3] takes rows. a[:, 1:4] takes columns. a[1:3, 1:4] takes both and gives you a rectangle. a[::2] takes every other row, and a[:, ::-1] reverses the columns.
This composability is most of what makes array code compact. A block of a matrix, a channel of an image, every second sample of a signal — all are one expression.
Worth knowing
One index per axis, separated by commas. a[1, 2] is direct; a[1][2] builds an intermediate row first.
Slices apply per axis, so a[1:3, 1:4] cuts out a rectangle.
A slice of an array is a view. Lists copy on slice; arrays do not, so writing through a slice changes the original.
Axes you do not mention are taken whole, and ... stands for as many as needed — a[..., 0] is the first of the last axis.
Assignment into a slice writes in place. A scalar fills the region; an array must match its shape.
An integer index removes that axis; a length-1 slice keeps it. A column taken as a[:, 2] will not broadcast back against its own array; a[:, 2:3] will.
Indexing and Slicing
Elements, rows and rectangles - and the one behaviour that differs from lists in a way that matters.
One index per axis
A comma separates axes. a[1, 2] is one element; a[1][2] gets there too, but builds a temporary on the way.
example_01.pyNumPy
Output
A comma separates axes, so a[1, 2] means row one, column two. That is the idiomatic form and the efficient one.
a[1][2] reaches the same element by first producing row one as an array, then indexing that. For reading a single value the difference is small; inside a loop it is a temporary array per iteration.
Negative indices count from the end, exactly as in Python. a[-1, -1] is the last element of the last row.
Indexing with fewer indices than axes gives you everything in the remaining ones. On a (3, 4) array, a[1] is a row of four.
Slices work per axis too
The familiar start:stop:step, once per dimension. Together they cut out a rectangle.
example_02.pyNumPy
Output
A slice is a VIEW, not a copy
This is the behaviour that differs from lists, and the one that surprises people who assume otherwise.
example_03.pyNumPy
Output
Ellipsis and missing axes
Trailing axes you do not mention are taken whole. ... stands in for as many as needed.
example_04.pyNumPy
Output
Assigning into a slice
The left-hand side selects where to write. A scalar fills the region; an array must fit it.
example_05.pyNumPy
Output
Integer index versus slice of length one
One drops the axis and the other keeps it. That single difference explains a lot of shape confusion later.
example_06.pyNumPy
Output
Slices are views
Here is the behaviour that differs from lists, and it catches nearly everybody once.
Slicing a list copies. Slicing an array gives you a view onto the same memory. Writing through the view changes the original.
arr = np.arange(5)
view = arr[1:4]
view[0] = 99 # arr is now [0, 99, 2, 3, 4]
That is deliberate and valuable: slicing a large array costs nothing, so you can pass windows around freely without copying gigabytes. It is also a source of bugs when a function slices its input, modifies the slice, and unintentionally alters the caller's data.
The defence is to be explicit. arr[1:4].copy() when you need independence, and np.shares_memory(a, b) when you are not sure what you have. There is a whole module on this later, because it applies to more operations than slicing.
Ellipsis
Trailing axes you do not mention are taken whole, so on a three-dimensional array a[0] gives you everything under index zero.
... stands for as many axes as needed, wherever it appears. a[..., 0] is the first element along the last axis, whatever the number of leading axes.
That is genuinely useful in code that handles arrays of varying dimensionality: a[..., -1] takes the last column of a 2-D array, the last channel of a 3-D one, and the same thing again for higher dimensions, without a special case.
Assigning into a selection
Anything you can select, you can assign to, and the assignment happens in place.
A scalar fills the whole region: a[1] = 5 sets every element of row one. An array must match the shape of the target — or be broadcastable to it, which is the next module.
Getting a length wrong raises rather than doing something surprising, which is the behaviour you want. It is also a reliable early sign that an upstream shape is not what you assumed.
The axis-dropping rule
This is small, easy to miss, and explains a great deal of later confusion.
An integer index removes that axis. a[1] on a (3, 4) array gives shape (4,).
A slice keeps it. a[1:2] gives shape (1, 4).
The same applies to columns: a[:, 2] is (3,) while a[:, 2:3] is (3, 1).
Both contain the same three numbers, and they behave completely differently the moment they meet another array.
Broadcasting aligns shapes from the right. Against a (3, 4) array, a (3,) column compares 4 with 3 and raises — even though it came out of that very array. The (3, 1) version compares 4 with 1, stretches, and works.
The last editor shows exactly that: the same column, taken two ways, one of which is an error. It is worth running, because "I sliced this out of the array so of course it fits" is a reasonable-sounding assumption that is simply false.
When an operation gives you a surprisingly large result, or complains about shapes that "obviously" match, this is the first thing to check.
Negative indices and reversal
Negative indices count from the end, exactly as in Python lists. a[-1] is the last element, a[-2] the second last.
They work per axis, so a[-1, -1] is the bottom-right element of a 2-D array, and a[:, -1] is the last column.
A negative step reverses. a[::-1] gives the array backwards, and it is a view — no data is copied, the stride is simply negative. That makes reversal free, which is why sorting descending is written as np.sort(a)[::-1] without any performance concern.
On more than one axis, each gets its own direction: a[::-1, :] reverses the rows and leaves the columns; a[::-1, ::-1] reverses both, which is a 180-degree rotation.
Steps
The third slice component is the step. a[::2] takes every second element, a[1::2] every second starting from the first.
Strided slices are views, because a regular step is exactly what a stride can express. That is worth knowing when working with large arrays: downsampling an image by taking every fourth pixel costs nothing until you write to the result.
Combining a step with a negative direction works but reads badly, and a[::-2] is one of the few slice forms worth writing a comment for.
Out of range: slices clip, indices raise
This asymmetry catches people.
a[100] on a ten-element array raises IndexError.
a[5:100] on the same array returns five elements. Slices clip silently to what exists.
Both behaviours are defensible — an out-of-range index has no meaningful answer, while an out-of-range slice does — but the inconsistency means a slice-based bug produces a short array rather than an exception, and the failure surfaces somewhere else.
If a slice must produce a specific length, check the length rather than assuming it. assert len(chunk) == n at the point of slicing localises the problem far better than a shape error three functions later.
Assignment through a selection
Anything you can select, you can assign to, and the value must either match the selection's shape or broadcast to it.
a[0] = 5 fills the first row with fives, broadcasting the scalar.
a[0] = [1, 2, 3] sets the row, requiring exactly three values.
a[0] = [1, 2] raises, because two values neither match three nor broadcast to it.
Assignment also converts silently to the array's dtype. Assigning 3.7 into an integer array stores 3. Assigning a long string into a fixed-width string array truncates it. Neither warns. This is a direct consequence of the array owning its dtype: the block cannot change type to accommodate what you put in it.
The rule that explains the shapes
There is one rule that predicts the shape of any basic selection:
An integer removes an axis. A slice keeps it.
a[0] on a (3, 4) array gives (4,) — the row axis is gone.
a[0:1] gives (1, 4) — the axis survives with length one.
a[:, 0] gives (3,); a[:, 0:1] gives (3, 1).
That last pair is the one that matters in practice, because the (3, 1) form broadcasts down a column and the (3,) form does not. When a broadcast fails on something you expected to work, an integer index that dropped an axis is a likely cause.
A note on tuples
a[0, 1] and a[(0, 1)] are the same thing — the comma builds a tuple, and NumPy interprets a tuple as one index per axis.
a[[0, 1]] is a list and means something else entirely: fancy indexing, selecting rows 0 and 1.
That distinction between a tuple and a list inside brackets is invisible at a glance and changes both the meaning and whether you get a view. It is worth being deliberate about, especially when the index is built programmatically — a list assembled in a loop and passed as an index does fancy indexing, and tuple(...) around it is the fix when you meant per-axis selection.
Indexing that is built programmatically
When an index is assembled at runtime rather than written literally, the tuple-versus-list distinction becomes a real hazard.
a[tuple(idx)] selects one element per axis. a[list(idx)] does fancy indexing along the first axis. The two produce completely different results from the same values, and neither raises.
For code that builds indices dynamically, tuple() around the result makes the intent explicit and matches what NumPy expects for per-axis selection.
np.s_ is a small convenience for storing a slice as a value: sl = np.s_[1:3, ::2] can be passed around and applied later as a[sl]. It is useful for configurable windows, and clearer than constructing slice(1, 3) objects by hand.
Views, once more
Every basic slice is a view. That is the single most consequential fact in this module, and it is worth stating in its practical form:
Writing into a slice writes into the original.
a[1:3] = 0 sets elements of a. b = a[1:3]; b += 1 also modifies a. A function that slices its argument and writes to the slice modifies the caller's array.
This is not a flaw. It is why slicing large arrays costs nothing and why NumPy code can pass windows around freely. But it differs from Python lists, where a[1:3] is a copy, and that difference is the source of a whole category of surprising bugs for people arriving from ordinary Python.
np.shares_memory(a, b) settles any specific case. copy() breaks the connection when you want it broken.
A summary of the selection forms
a[i] — one element or one sub-array; drops an axis.
a[i:j] — a range; keeps the axis; a view.
a[i:j:k] — a strided range; still a view.
a[::-1] — reversed; still a view.
a[i, j] — one index per axis, via a tuple.
a[..., j] — the last axis, whatever the dimensionality.
a[None] — a new length-1 axis at the front.
a[mask] — boolean selection; a copy.
a[[i, j]] — fancy indexing; a copy; can reorder and repeat.
The first seven are views and free. The last two allocate, and get modules of their own. Knowing which group an expression falls into predicts both its cost and whether writing to the result affects the original — which between them explains most of NumPy's indexing behaviour.
A closing note
Indexing is where NumPy diverges most visibly from ordinary Python, in two ways that both matter.
A slice is a view rather than a copy, so writing through it reaches the original. And an integer index drops an axis while a length-one slice keeps it, which decides whether a later broadcast succeeds.
Neither is complicated, and both explain a large share of the surprises that follow in later modules.
Check yourself
0 of 4
Answer without scrolling back up.
You slice an array and modify the slice. What happens to the original?
This is where arrays differ from lists. Slicing costs nothing because nothing is copied - and a function that modifies a slice of its input alters the caller's data.
On a (3,4) array `a`, what does `a + a[:, 2]` do?
Shapes align from the right, so a column taken with an integer index will not broadcast back against its own array. `a[:, 2:3]` keeps the axis and works.
What does `a[..., 0]` select?
`...` stands for as many axes as needed, so this works regardless of how many leading dimensions the array has.
How do you slice without risking changes to the original?
`arr[1:4].copy()` gives independence. `np.shares_memory(a, b)` tells you what you actually have when you are unsure.
Cheat sheet
Indexing and Slicing
a[1:3] takes rows. a[:, 1:4] takes columns. a[1:3, 1:4] takes both and gives you a rectangle. a[::2] takes every other row, and a[:, ::-1] reverses the columns.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.