Fancy Indexing

Selecting by a list of positions - reordering, repeating, and building a result whose shape follows the index.

Overview

Indexing with an array

Give an integer array where you would normally give a number, and you get those elements back:

a[[0, 2, 4]]

Order is preserved as given, so this is also how you reorder. Positions may repeat, so it is also how you tile or expand. Negative indices work as usual.

The result takes the shape of the index, not the source. Indexing a 1-D array with a (2, 2) index array gives a (2, 2) result. That is occasionally what you want and is worth knowing before it surprises you.

Worth knowing

Indexing with an array of positions selects those elements in that order, repeats included.
The result takes the shape of the index, not of the source array.
Fancy indexing always copies. Assigning through it writes in place.
With repeated positions, a[[0,0,0]] += 1 adds once, not three times. Use np.add.at to accumulate.
Two index arrays are matched elementwise into pairs, not crossed into a rectangle — use np.ix_ for the rectangle.
argsort plus fancy indexing is how you sort one array by another and keep parallel arrays aligned.

Fancy Indexing

Selecting by a list of positions - reordering, repeating, and a result whose shape follows the index.

Indexing with a list of positions

Give an array of indices and you get those elements, in that order, however many times you ask.

example_01.pyNumPy
Output

It always copies

There is no stride pattern that describes arbitrary positions, so the result is new memory every time.

example_02.pyNumPy
Output

Two dimensions: pairs, not a rectangle

Index arrays for each axis are matched elementwise. That is different from slicing, and it is the usual surprise.

example_03.pyNumPy
Output

Selecting whole rows or columns

The most common use: reordering, sampling, or taking a subset of records.

example_04.pyNumPy
Output

Sorting one array by another

argsort gives positions, and fancy indexing applies them — which keeps parallel arrays aligned.

example_05.pyNumPy
Output

take, put and the fast path

np.take is fancy indexing with options, and it is usually a little quicker on large arrays.

example_06.pyNumPy
Output

It copies

Fancy indexing cannot return a view. The selected positions are arbitrary, and a view has to be describable as a start, a shape and a set of strides — which scattered positions are not.

So a[[0, 2, 4]] is new memory, and writing to it leaves the original alone.

Assigning through it is different: a[[0, 2, 4]] = -1 writes into the original in place. Same as with boolean masks — reading copies, writing does not.

Repeated positions in an assignment

A sharp edge worth knowing about.

b[[0, 0, 0]] += 1

This does not add three. It reads the value once, adds one, and writes the result back three times, so the answer is one.

That is a consequence of how augmented assignment is defined rather than a bug, and it catches people building histograms or accumulating into bins.

np.add.at(b, [0, 0, 0], 1) does the accumulating version. It is slower, because it cannot use the vectorised path, and it is correct. For counting specifically, np.bincount is faster still.

Two dimensions

This is the main surprise.

a[[0, 2], [1, 3]]

does not select rows 0 and 2 crossed with columns 1 and 3. It pairs them elementwise and returns the two elements at (0, 1) and (2, 3).

That behaviour is consistent — the index arrays broadcast against each other, then each pair is a coordinate — and it is not what most people expect the first time.

For the rectangle, there are two idioms. a[np.ix_(rows, cols)] builds the open mesh for you and is the clearest. Or reshape one index to a column so the two broadcast into a grid: a[np.array(rows)[:, None], cols].

Once you have seen that the second form is just broadcasting applied to indices, the rule stops being a special case.

The everyday uses

Reordering rows: data[[3, 0, 4]].

Selecting columns: data[:, [2, 0]].

Sampling: data[rng.choice(len(data), size=n, replace=False)].

Sorting by a key: argsort returns positions, and applying the same positions to several arrays keeps them aligned. That is the main reason argsort exists rather than just sort — the permutation is reusable.

take

np.take(a, idx) is fancy indexing as a function. It is often slightly faster on large arrays, and it takes a mode argument that plain indexing does not:

mode='raise' is the default and matches a[idx]. mode='clip' pins out-of-range indices to the ends. mode='wrap' wraps them around.

Those modes are useful for boundary handling — a filter that would read past the edge, for instance — where the alternative is padding the array or special-casing the ends.

Mixing fancy indexing with slices

Combining the two in one expression is where fancy indexing stops being intuitive.

a[[0, 2], 1:3] works and does what you expect: rows 0 and 2, columns 1 to 2.

But when fancy indices appear on both sides of a slice — a[[0, 2], :, [1, 3]] — NumPy has to decide where the resulting axis goes, and the rule is that the fancy-indexed axis moves to the front. The result's shape is not the one most people predict.

The practical advice is to avoid the ambiguous forms. If an expression mixes fancy indices and slices in a way that makes you pause, split it into two steps. Two clear selections cost one extra intermediate array and save the next reader from working out the rule.

np.ix_ builds the rectangle

The most common thing people want from a[[0, 2], [1, 3]] is the four elements at the intersection of rows 0 and 2 with columns 1 and 3.

What they get is two elements: a[0,1] and a[2,3], because the index arrays are paired elementwise.

np.ix_ converts a set of index lists into the shapes that broadcast into a grid:

a[np.ix_([0, 2], [1, 3])]

gives the (2, 2) rectangle. It works by reshaping the first list to a column and the second to a row, so broadcasting produces every combination — the same trick as a[:, None] against b[None, :], packaged for readability.

The alternative, a[[0, 2]][:, [1, 3]], gives the same answer with two selections and two copies. np.ix_ is one selection and says what it means.

put, take and the clip modes

np.take(a, idx) is fancy indexing as a function call. It is usually a little faster than bracket syntax on 1-D arrays, and it accepts an axis argument, which makes np.take(a, idx, axis=1) a clean way to select columns without a slice full of colons.

Its useful extra is mode. By default an out-of-range index raises, as with normal indexing. mode="clip" pins the index to the valid range, and mode="wrap" wraps it around.

That turns a whole class of boundary-condition loops into a single call — sampling neighbours near the edge of an image, for instance, where the choice between clamping and wrapping is a parameter rather than a branch.

np.put is the assignment counterpart, writing values at given flat positions in place.

The idiom this all exists for

Fancy indexing looks like a collection of tricks until you see the one pattern it is really for:

order = np.argsort(key)
table_a = table_a[order]
table_b = table_b[order]

Compute an index array once, apply it to everything that needs to stay aligned. That is sorting a table by a column, taking the top n, shuffling a dataset, applying a train/test split, and reordering to match another array — all the same operation.

Once you recognise it, most uses of fancy indexing in real code turn out to be an instance of it, and the rest are lookups.

Lookups

The second everyday use is treating an array as a lookup table.

labels[predictions] converts an array of class indices into an array of names. palette[image] converts an index image into colours. np.unique(..., return_inverse=True) produces exactly the index array that this consumes.

The pattern is values[index_array], and the result has the shape of the *index*, not of the values. That is worth stating explicitly, because it is the opposite of the intuition built up from slicing, and it is what makes a (h, w) index array plus a (256, 3) palette produce an (h, w, 3) image.

Choosing between the three ways to select

Basic slicing when the selection is a regular range. It is a view, it is free, and it is the only one of the three that does not allocate.

Boolean masking when the selection is a condition. The mask has the shape of the data, and it composes with & and |.

Fancy indexing when the selection is a list of positions, or when the order matters. Masks cannot reorder or repeat; fancy indexing can do both.

The overlap is smaller than it looks. A condition wants a mask. A permutation wants an index array. A contiguous window wants a slice. When two of them would work, the one that expresses the intent directly is the right choice, and it is usually also the faster one.

Repeated indices in assignment

Selecting with repeated indices returns repeated values, which is unsurprising.

Assigning with repeated indices is where it gets interesting. a[[0, 0, 1]] = [10, 20, 30] leaves element 0 holding 20 — the last write wins, and the earlier one is simply overwritten.

The version that catches people is the augmented form:

a[[0, 0, 1]] += 1

Element 0 is incremented once, not twice. The expression expands to a fetch, an add and a store: the fetch produces a copy containing element 0 twice, both copies get incremented, and both are written back to the same location.

np.add.at(a, [0, 0, 1], 1) is the unbuffered version that does what the syntax suggests. Every ufunc has an .at method for this.

This matters in any accumulation where indices repeat — building a histogram, scattering values into bins, accumulating gradients. The buffered version produces a plausible undercount rather than an error, which is the worst kind of wrong.

For the specific case of counting or summing by integer key, np.bincount(idx) and np.bincount(idx, weights=vals) are both correct and considerably faster than add.at.

Negative indices work

Fancy indexing accepts negative positions, counting from the end exactly as normal indexing does. a[[-1, -2]] takes the last two elements in reverse order.

That is convenient and occasionally a hazard: an index array computed by subtraction that accidentally goes negative selects from the far end of the array instead of raising. A -1 produced by "not found" logic silently returns the last element.

If out-of-range should be an error rather than a wrap, check the index array before using it, or use np.take with the default mode="raise", which validates but treats negatives the same way.

The cost

Fancy indexing always allocates. The result is a new array of the index's shape, and the values are gathered one by one from scattered positions.

That gathering is not free even beyond the allocation: scattered reads defeat the processor's cache in the way covered in the performance module. Fancy indexing a large array with a random permutation is meaningfully slower per element than reading it in order.

The practical consequence is small — it is still far faster than a Python loop — but it means fancy indexing inside a tight loop over a large array is worth a second look. Sorting the index array first, where order does not matter, can help by making the reads more sequential.

The three selections, compared

Basic slicingBoolean maskFancy indexing
Resultviewcopycopy
Selects byposition rangeconditionexplicit positions
Can reorderonly reversalnoyes
Can repeatnonoyes
Result shapederived from slice1-D, length = matchesshape of the index

The last row is the one that surprises people most. A boolean mask always flattens to a 1-D result, however many dimensions it covered. Fancy indexing takes the shape of the index array, so a (h, w) index array against a (256, 3) palette produces (h, w, 3).

Choosing correctly is mostly a matter of matching the tool to the question: a range wants a slice, a condition wants a mask, a list of positions wants fancy indexing. Where two would work, the one that states the intent is usually also the faster one.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What does `a[[0, 2], [1, 3]]` select from a 2-D array?

  2. Does fancy indexing return a view?

  3. What does `b[[0,0,0]] += 1` do to b[0]?

  4. Why does `argsort` exist rather than just `sort`?

Cheat sheet

Fancy Indexing

Order is preserved as given, so this is also how you reorder. Positions may repeat, so it is also how you tile or expand. Negative indices work as usual.

NUMPY · vizlearn.in/numpy/fancy_indexing.html

About the author

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.