Getting at elements, rows and rectangles - and the one behaviour that differs from lists in a way that matters.
Overview
Slices, per axis
Indexing and slicing are the operations you perform most often on a NumPy array. They control which elements you read, which you write, and how you carve sub-arrays out of a larger block. Getting them right is the foundation for every later topic.
NumPy extends the familiar Python slice syntax to multiple dimensions. Each axis gets its own start, stop and step, separated by commas. A single expression can select a rectangular block from a matrix, every other row from a 3-D tensor, or a reversed column from a table. That composability is what makes array code compact.
The key difference from Python lists is that a NumPy slice returns a view, not a copy. The slice and the original share the same memory. Writing through the slice changes the original. That behaviour is a deliberate design choice: it saves memory and makes in-place updates natural. But it also means that modifying a slice can change data you thought was separate, which surprises anyone coming from Python lists.
Integer indexing also behaves differently across dimensions. A comma separates axes, so a single expression addresses a specific element without building intermediate arrays. That matters for both readability and performance.
Beyond basic indexing, NumPy supports boolean masks and fancy indexing with arrays of indices. Those are covered in their own modules. The concepts here -- per-axis indexing, slice views, and the comma separator -- are the foundation those later topics build on.
This module covers single-element access, slicing with start, stop and step, mixed integer-and-slice expressions, and the view behaviour that differs from lists. The examples use small arrays where you can verify the result by counting elements.
The familiar start:stop:step, once per dim.
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. a[:, ::-1] reverses the columns.
This composability is most of what makes array code compact. A block of a mat, a channel of an image, each second sample of a signal. All are one formula.
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 action 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
import numpy as np
a = np.arange(12).reshape(3, 4)
print(a)
print()
print("a[1, 2] :", a[1, 2], "<- row 1, column 2")
print("a[1][2] :", a[1][2], "<- same value, via a temporary row")
print("a[-1, -1]:", a[-1, -1], "<- negatives count from the end")
print()
print("a[1] :", a[1], "shape", a[1].shape, "<- a whole row")
print("a[:, 1] :", a[:, 1], "shape", a[:, 1].shape, "<- a whole column")
Output
Indexing a NumPy array works differently from indexing a Python list. The syntax looks similar, but the semantics differ in ways that matter for both correctness and performance.
The fundamental rule is one index per axis, separated by commas. A 2-D array uses two indices: one for the row, one for the column. Writing a[1, 2] gives you the element at row 1, column 2. That comma-separated form is both the idiomatic style and the faster one.
The alternative, a[1][2], works but creates an intermediate array. The first index selects the entire row as a 1-D array. The second index then selects from that temporary. For a single element the overhead is small. Inside a loop it adds up. More importantly, the chained form does not generalize to fancier indexing patterns.
Slicing returns a view, not a copy. A slice like a[0:3, 1:4] gives you a rectangular sub-array that shares memory with the original. Writing to the slice changes the original. That is a deliberate design choice. It saves memory and makes slice assignment a natural way to fill parts of an array.
The combination of integer indexing and slicing in the same expression is allowed. a[:, 2] gives you all rows of column 2 as a 1-D array. a[1, :] gives you all columns of row 1. These mixed forms appear constantly in real code.
Step values work as in Python lists. a[::2] gives every other element. a[::-1] reverses. Both return views. Combining steps across multiple axes selects a regular sub-grid of the array.
This module covers single-element indexing, slicing, step values, and the difference between views and copies in the context of slicing. Understanding these basics makes the next modules on boolean masking and fancy indexing straightforward.
A comma splits axes, so a[1, 2] means row one, column two. That is the idiomatic form and the fast one.
a[1][2] reaches the same element by first making row one as an array, then indexing that. For reading a single value the gap is small. Inside a loop it's a temporary array per loop.
Minus indices count from the end, just as in Python. a[-1, -1] is the last element of the last row.
Indexing with fewer indices than axes gives you all in the left 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
import numpy as np
a = np.arange(20).reshape(4, 5)
print(a)
print()
print("a[1:3] rows 1-2:"); print(a[1:3])
print()
print("a[1:3, 1:4] a block:"); print(a[1:3, 1:4])
print()
print("a[::2] every other row:"); print(a[::2])
print()
print("a[:, ::-1] columns reversed:"); print(a[:, ::-1])
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
import numpy as np
# Lists: slicing copies.
lst = [0, 1, 2, 3, 4]
part = lst[1:4]
part[0] = 99
print("list :", lst, "<- unchanged")
# Arrays: slicing views.
arr = np.arange(5)
view = arr[1:4]
view[0] = 99
print("array :", arr, "<- CHANGED through the slice")
print()
print("shares memory:", np.shares_memory(arr, view))
print("view.base is arr:", view.base is arr)
print()
print("Take an explicit copy when you want independence:")
safe = arr[1:4].copy()
safe[0] = -1
print(" after writing to the copy:", arr)
Output
Ellipsis and missing axes
Trailing axes you do not mention are taken whole. ... stands in for as many as needed.
example_04.pyNumPy
import numpy as np
a = np.arange(24).reshape(2, 3, 4)
print("shape:", a.shape)
print()
print("a[0] ->", a[0].shape, "(the rest taken whole)")
print("a[0, 1] ->", a[0, 1].shape)
print("a[0, 1, 2] ->", a[0, 1, 2])
print()
print("a[..., 0] ->", a[..., 0].shape, "<- first of the LAST axis")
print("a[0, ...] ->", a[0, ...].shape, "<- same as a[0]")
print()
print("Useful when the number of leading axes varies:")
print(" last column of any shaped array:", a[..., -1].shape)
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
import numpy as np
a = np.zeros((3, 4), dtype=int)
a[1] = 5 # a whole row
a[:, 0] = 9 # a whole column
a[2, 1:3] = [7, 8] # a matching run
print(a)
print()
try:
a[0] = [1, 2] # 2 values into 4 slots
except ValueError as e:
print("wrong length:", type(e).__name__)
print(" ", e)
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
import numpy as np
a = np.arange(12).reshape(3, 4)
print("a[1] shape", a[1].shape, "<- integer: axis removed")
print("a[1:2] shape", a[1:2].shape, "<- slice: axis kept, length 1")
print()
print("a[:, 2] shape", a[:, 2].shape)
print("a[:, 2:3] shape", a[:, 2:3].shape)
print()
print("It matters the moment you combine them:")
col_flat = a[:, 2] # shape (3,)
col_2d = a[:, 2:3] # shape (3, 1)
try:
a + col_flat
except ValueError as e:
print(" a + (3,) :", type(e).__name__)
print(" ", e)
print(" a + (3, 1) :", (a + col_2d).shape, "<- this one works")
print()
print("Shapes line up from the RIGHT. (3,4) against (3,) compares 4 with 3")
print("and fails; against (3,1) it compares 4 with 1 and stretches.")
Output
Slices are views
Here is the action 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 first.
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. You can pass windows around freely without copying gigabytes. It's also a source of bugs when a function slices its input, modifies the slice. Unintentionally alters the caller's data.
The defence is to be clear. arr[1:4].copy() when you need freedom. np.shares_memory(a, b) when you aren't sure what you've. There's a whole module on this later. It applies to more steps than slicing.
Ellipsis
Trailing axes you don't mention are taken whole. On a three-dimensional array a[0] gives you all under index zero.
... stands for as many axes as needed, wherever it appears. a[..., 0] is the first element along the last axis, what the number of leading axes.
That is truly handy 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. The same thing again for higher dims, without a special case.
Assigning into a selection
Anything you can select, you can assign to, and the set happens in place.
A scalar fills the whole region: a[1] = 5 sets each element of row one. An array must match the shape of the target. Or be broadcastable to it. This Is the next module.
Getting a length wrong raises rather than doing a thing surprising. This Is the action you want. It's also a solid early sign that an upstream shape isn't what you assumed.
The axis-dropping rule
This is small, easy to miss, and shows a great deal of later confusion.
An int index drops 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 hold the same three numbers. They behave fully differently the moment they meet another array.
Broadcasting aligns shapes from the right. Against a (3, 4) array, a (3,) column checks 4 with 3 and raises. though it came out of that very array. The (3, 1) version checks 4 with 1, stretches, and works.
The last editor shows just that: the same column, taken two ways, one of which is an error. It's worth running. "I sliced this out of the array so it fits" is a fair-sounding assumption that is simply false.
When an step gives you a oddly large result, or complains about shapes that "obviously" match, this is the first thing to check.
Negative indices and reversal
Minus indices count from the end, just 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. a[:, -1] is the last column.
A minus step reverses. a[::-1] gives the array backwards. It's a view. No data is copied, the stride is simply minus. That makes reversal free. This Is why sorting descending is written as np.sort(a)[::-1] without any speed 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. This Is a 180-degree rotation.
Steps
The third slice part is the step. a[::2] takes each second element, a[1::2] each second starting from the first.
Strided slices are views, because a regular step is just what a stride can express. That is worth knowing when working with large arrays. Downsampling an image by taking each fourth pixel costs nothing until you write to the result.
Mixing a step with a minus direction works but reads badly. a[::-2] is one of the few slice forms worth writing a comment for.
Out of range: slices clip, indices raise
This skew 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 actions are defensible — an out-of-range index has no real answer, while an out-of-range slice does — but the inconsistency means a slice-based bug makes a short array rather than an exception. The failure surfaces one spot else.
If a slice must give a exact length, check the length rather than given 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. The value must either match the pick'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, needing just three values.
a[0] = [1, 2] raises. Two values neither match three nor broadcast to it.
set also converts silently to the array's dtype. Assigning 3.7 into an int array stores 3. Assigning a long string into a fixed-width string array truncates it. Neither warns. This is a direct result of the array owning its dtype. The block can't change type to accommodate what you put in it.
The rule that explains the shapes
There's one rule that predicts the shape of any basic pick:
An int drops 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 lives with length one.
a[:, 0] gives (3,). a[:, 0:1] gives (3, 1).
That last pair is the one that matters because the (3, 1) form broadcasts down a column and the (3,) form doesn't. When a broadcast fails on a thing you expected to work, an int 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. NumPy reads a tuple as one index per axis.
a[[0, 1]] is a list and means a thing else fully: fancy indexing, selecting rows 0 and 1.
That gap 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's worth being deliberate about, above all when the index is built programmatically. A list assembled in a loop and passed as an index does fancy indexing. tuple(...) around it's the fix when you meant per-axis pick.
Indexing that is built programmatically
When an index is assembled at runtime rather than written truly, the tuple-versus-list gap turns a real hazard.
a[tuple(idx)] selects one element per axis. a[list(idx)] does fancy indexing along the first axis. The two give fully other results from the same values, and neither raises.
For code that builds indices dynamically, tuple() around the result makes the intent clear and matches what NumPy expects for per-axis pick.
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's handy for configurable windows. Clearer than making slice(1, 3) objects by hand.
Views, once more
each basic slice is a view. That is the single most consequential fact in this module. It's worth stating in its hands-on form:
Writing into a slice writes into the first.
a[1:3] = 0 sets elements of a. b = a[1:3]. B += 1 also modifies a. A function that slices its arg and writes to the slice modifies the caller's array.
This isn't a flaw. It's why slicing large arrays costs nothing and why NumPy code can pass windows around freely. But it differs from Python lists. Here a[1:3] is a copy. That gap is the source of a whole group of surprising bugs for people arriving from plain Python.
np.shares_memory(a, b) settles any exact case. copy() breaks the link 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, what the dimensionality.
a[None] — a new length-1 axis at the front.
a[mask] — boolean pick. A copy.
a[[i, j]] — fancy indexing. A copy. Can reorder and repeat.
The first seven are views and free. The last two allocate. Get modules of their own. Knowing which group an formula falls into predicts both its cost and whether writing to the result affects the first. Which between them shows most of NumPy's indexing action.
Questions people ask about Indexing and Slicing
What is the difference between a[1][2] and a[1, 2]? They give the same value on a 2D array, but a[1][2] builds an intermediate row first. Prefer the comma form, which is faster and works with slices.
Do slices copy the data? No, a slice is a view onto the same memory. Changing it changes the original, so call .copy() when you need independence.
What does a[::-1] do? It reverses the array along that axis using a negative step. The result is a view, not a copy.
Why does indexing one row drop a dimension? An integer index removes that axis, while a slice keeps it. a[1] has shape (n,) but a[1:2] has shape (1, n).
What does the ellipsis mean in an index? It stands in for as many full slices as needed. a[..., 0] picks the first entry along the last axis whatever the number of dimensions.
What happens if a slice goes past the end? Slices clip silently and never raise. A single integer index out of range does raise an IndexError.
A closing note on Indexing and Slicing
Indexing is where NumPy diverges most visibly from plain Python, in two ways that both matter.
A slice is a view rather than a copy. Writing through it reaches the first. And an int index drops an axis while a length-one slice keeps it. This 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
Slice rows, columns and blocks from a NumPy array. Learn the one slicing behaviour that differs from Python lists and why it matters.
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.