Transpose and Moving Axes

Reordering axes without moving a single byte - and the one case where .T silently does nothing.

Overview

Strides again

Transposing an array swaps its axes. For a 2-D array, rows become columns and columns become rows. For higher-dimensional arrays, the axes are permuted in a specified order. The operation is instant because no data is moved. NumPy simply changes the strides and the shape in the array description.

That zero-cost property is worth understanding. A 2-D array with shape (1000, 2000) uses the same memory as its transpose. np.shares_memory confirms that the transpose is a view of the original. Writing to the transpose changes the original, because they are the same block of bytes with different descriptions.

The strides tell NumPy how many bytes to step for each axis. A C-contiguous (1000, 2000) float64 array has strides (16000, 8). Each row is 16000 bytes long. Each element within a row is 8 bytes. The transpose swaps the strides to (8, 16000). Now stepping along the first axis moves 8 bytes, which walks through what used to be columns.

For 2-D arrays, the shorthand a.T is the standard notation. For higher dimensions, np.transpose with an axes argument gives you full control. np.swapaxes swaps exactly two axes, which is simpler when that is all you need.

The practical consequence of strides is memory layout. A C-contiguous array has its last axis changing fastest. A Fortran-contiguous array has its first axis changing fastest. A transposed C-contiguous array is Fortran-contiguous. Operations that scan along the wrong axis are slower because they jump through memory instead of walking sequentially.

That performance difference is small for most work. It matters for tight numerical loops and for interoperating with libraries that expect a specific memory order. np.ascontiguousarray copies the data into C order when you need it.

This module covers transpose for 2-D and higher-dimensional arrays, the strides that make it free, the memory-order conventions, and swapaxes and moveaxis for targeted axis manipulation. The examples verify the view property and show how strides change.

An array knows how many bytes to step for each axis. Transposing swaps those numbers along with the shape. The underlying buffer is untouched.

That is why a.T on a gigabyte array returns instantly. It's a view, and np.shares_memory(a, a.T) confirms it.

all else in this module follows from that one fact.

Worth knowing

.T reverses the axes by swapping strides. No data moves, so transposing a huge array is free.
.T on a 1-D array does nothing. Use v[:, None] to get a column.
On three or more axes .T reverses all of them. Use transpose(...) with an explicit order instead.
swapaxes exchanges two axes and moveaxis relocates one — both read better than counting positions.
A transposed array is no longer C-contiguous, so ravel and some reshape calls will quietly copy.
Transpose reflects along the diagonal; it is not a rotation. rot90, flipud and fliplr are the geometric operations.

Transpose and Moving Axes

Reordering axes without moving a single byte.

.T reverses the axes

It is a view: the strides swap and the data never moves.

example_01.pyNumPy
Output

The 1-D trap

.T on a 1-D array is a no-op. This surprises everyone once.

example_02.pyNumPy
Output

v.T where v has shape (3,) gives shape (3,). Nothing happens.

There's no second axis to swap. The step is meaningless and NumPy performs it silently rather than raising. People coming from MATLAB or from linear algebra form expect a column vector and get their input back.

The fix is to add an axis: v[:, None] gives (3, 1). v[None, :] gives (1, 3). reshape(-1, 1) does the same job.

This is also why an outer-style step is written v[:, None] - v rather than v.T - v. The first broadcasts (3,1) against (3,) to give (3,3). The second subtracts an array from itself and gives zeros.

transpose with an explicit order

On more than two axes, .T reverses all of them - which is rarely what you want. Name the order instead.

example_03.pyNumPy
Output

swapaxes and moveaxis read better

Two axes to exchange, or one axis to relocate - both clearer than counting positions.

example_04.pyNumPy
Output

Transposing makes the memory non-contiguous

Which is fine, until something needs a contiguous buffer and quietly copies.

example_05.pyNumPy
Output

Transpose is not a rotation

It reflects along the diagonal. If you wanted to turn an image, rot90 is the function.

example_06.pyNumPy
Output

More than two axes

.T reverses all axes. On shape (2, 3, 4) it gives (4, 3, 2).

That is well defined but rarely what anyone wants. With three or more axes the notable step is often moving one exact axis one spot.

a.transpose(2, 0, 1) names the new order by old spots. Old axis 2 first, then old axis 0, then old axis 1. Reading it in that direction. "where does each new axis come from". Is the way to keep it straight.

swapaxes and moveaxis

Both are clearer than counting.

np.swapaxes(a, 0, 2) exchanges two axes and leaves the rest alone.

np.moveaxis(a, 0, -1) takes axis 0 and puts it at the end, sliding the others along. This is the canonical fix for image layout. Deep learning frameworks want (channels, height, width), image libraries want (height, width, channels). moveaxis converts between them for free.

Both return views.

Contiguity, and the copy you did not ask for

An array created by reshape on fresh data is C-contiguous. The last axis varies fastest and the elements sit in memory in the order you'd read them.

Transposing breaks that. The transposed view is F-contiguous instead. The same buffer, read column-first.

Most steps don't care. But anything that needs a flat contiguous buffer must copy:

ravel() returns a view when it can and a copy when it can't. On a transposed array, it can't.

reshape has the same action. This Is why "reshape returns a view" carries a *often*.

Neither warns you. If it matters. Because the array is large, or because you were leaning on writing through the result. Check with np.shares_memory, or force the issue with np.ascontiguousarray so the copy is clear and happens where you can see it.

Transpose is not rotation

This trips people up on image data.

Transpose reflects along the main main-line. Rotating a quarter turn is np.rot90. Mirroring is np.flipud (up-down) and np.fliplr (left-right).

They're related — a rotation is a transpose followed by a flip — but they're other steps. Reaching for .T to turn an image gives a mirrored result that looks almost right and isn't.

C order and F order, stated plainly

An array's elements are stored in one flat run of memory. The order decides which axis varies fastest as you walk that run.

C order — NumPy's default, named for the C language — means the last axis varies fastest. Row by row.

Fortran order means the first axis varies fastest. Column by column.

A transposed C-sorted array is F-contiguous. Reading the transpose row by row is reading the first column by column. Nothing moved. The same bytes are simply given differently.

Both flags can be true at once for a 1-D array or an array with a length-1 axis. Both can be false for a strided slice like a[::2]. a.flags reports all of it.

Where this turns hands-on: some LAPACK routines want Fortran order and will copy inside if they don't get it. Interfacing with Fortran or MATLAB-derived code at times needs np.asfortranarray. For all else, staying in C order and not thinking about it's right.

expand_dims and squeeze at boundaries

The length-1 axis is the currency of shape negotiation between libraries.

A model trained on batches expects (batch, features) and gets a single sample of shape (features,). x[None] or np.expand_dims(x, 0) adds the batch axis.

A result comes back as (n, 1) and the next function wants (n,). squeeze drops it.

expand_dims(a, axis) is the clear form of a[None]. Reads better when the axis spot is found rather than literal.

squeeze with no arg drops each length-1 axis. This Is the source of a truly nasty intermittent bug. A batch of one sample loses its batch dim. The code works for each batch size except one. Always name the axis. squeeze(axis=1). In code that handles var batch sizes.

einsum as general axis manipulation

Once an step needs a transpose, two newaxis insertions and a sum over a given axis, np.einsum often expresses it more clearly.

The subscript string names an index for each axis of each operand and says which live:

"ij->ji" is a transpose. "ii->i" extracts a main-line. "ij->j" sums over rows. "ij,jk->ik" is mat product. "bij,bjk->bik" is batched mat product.

Indices that appear on the left but not the right are summed over. Indices repeated between operands are matched and times'd.

It isn't always fast — for plain mat products, @ dispatches to a tuned BLAS routine and einsum may not. Its edge is legibility. The string states the step. Here A chain of transposes and broadcasts states only the mechanics.

np.einsum_path can be used to inspect and optimise the contraction order for expressions with a few operands. Here The order truly matters.

The batch-axis convention

Most numeric code follows one rule: the batch axis comes first. The axes being operated on come last.

(batch, height, width, channels) for images in TensorFlow. (batch, channels, height, width) in PyTorch. (samples, features) for tabular data.

The reason the operated-on axes go last is broadcasting. Trailing axes align on its own. A per-channel or per-feature param of shape (channels,) mixes with the data without any index juggling. Put the channel axis first and the same step needs [:, None, None].

matmul follows the same rule: it treats the last two axes as the mat and broadcasts all before them. A (batch, n, k) @ (k, m) works right.

Following the rule in your own arrays means broadcasting and matmul cooperate with you rather than needing a transpose at each boundary.

In practice

Use .T freely on 2-D and never on 1-D.

Use moveaxis or swapaxes on higher dims. They name what you meant and .T reverses all.

note that all of them return views over the same bytes. A later ravel or reshape may copy without telling you. Check with np.shares_memory when it matters, or force it with np.ascontiguousarray.

And keep the batch axis first. Most of the shape gymnastics people write is the cost of having put it one spot else.

Diagonals and axis-aware helpers

np.main-line(a) extracts the main main-line and returns a view in modern NumPy. Read-only. Writing to it has no steady meaning across the strides involved.

np.trace(a) sums the main-line without materialising it.

np.fill_diagonal(a, value) writes to the main-line in place. This Is the supported way to modify it. It's the standard step when building an adjacency or distance mat that should have zeros on the main-line.

offset on main-line selects the super- or sub-diags. This Is how you extract a band.

rollaxis, and why not to use it

Older code uses np.rollaxis. This Moves an axis but with arg semantics that are truly hard to reason about. The destination is read differently based on direction.

np.moveaxis was added namely to replace it, with the obvious semantics: source and destination, both plain spots.

If you meet rollaxis in existing code, translating it to moveaxis and checking the resulting shape is often worth doing while you're there. If you're writing new code, there's no reason to use it.

Checking that a transform did what you meant

Axis handling is the easiest place in NumPy to give a result that has the right shape and the wrong contents.

Two checks catch nearly all of it.

Verify a known element. After moveaxis(a, 0, -1), confirm that b[i, j, k] == a[k, i, j]. One assertion docs the transform better than a comment does.

Use distinguishable test data. np.arange(24).reshape(2, 3, 4) has a unique value in each spot. Any misplacement is visible. An array of ones or of random values hides just the errors you're looking for.

This is worth doing because the failure mode. Correct shape, permuted contents. Makes no error and often no obviously wrong output, just results that are subtly worse than they should be.

The summary

.T — reverses all axes. Right for 2-D, wrong for most higher-dimensional intent. Silently a no-op on 1-D.

transpose(order) — clear permutation, read as "where each new axis comes from".

swapaxes(i, j) — exchange two, leave the rest.

moveaxis(src, dst) — relocate one, slide the rest. The clearest of the four. The right default for anything above 2-D.

expand_dims / squeeze — add and drop length-1 axes, with squeeze needing an clear axis in any code that handles var batch sizes.

einsum — when the index bookkeeping has become the substance of the step.

All of them return views. That is what makes them free. It's also why a later ravel or reshape may copy without saying so. np.shares_memory when it matters, np.ascontiguousarray when you want the copy to happen one spot visible.

A closing note on cost

each step in this module is free. That is worth stating once more because it changes how you write code.

There's no reason to avoid a transpose, a moveaxis or an expand_dims on speed grounds. They adjust a handful of ints showing the array and return at once, what its size.

The cost, when it comes, arrives later. At the point a thing needs contiguous memory and quietly copies. That is where to look when a pipeline is slower than the steps in it suggest. a.flags plus np.shares_memory will tell you within a minute.

Questions people ask about Transpose and Moving Axes

Does transpose copy the data? No. It returns a view with the strides swapped, so it is instant. Some later operations may then need to copy.

What does a.T do for a 1D array? Nothing, because there is only one axis. Turn the vector into a column with a[:, None] if you need a 2D shape.

How do I transpose a 3D array? Use a.transpose(order), listing the new axis order, for example a.transpose(2, 0, 1). a.T just reverses all the axes.

What do swapaxes and moveaxis do? swapaxes exchanges two axes. moveaxis moves one axis to a new position and leaves the others in order, which is often clearer.

Why is my transposed array slower to process? It is no longer C-contiguous, so memory access jumps around. np.ascontiguousarray makes a copy in the order you want.

How do I turn images from HWC to CHW? Use np.moveaxis(img, -1, 0) or img.transpose(2, 0, 1). Both are views until you force a copy.

Where axis work fits

Reordering axes is almost always a translation step: between how data arrived and how the next function wants it.

That means the right question is rarely "how do I transpose this" but "what layout does the thing I am calling expect". Answering that first often reveals that one moveaxis at the boundary replaces a few scattered ones inside. That keeping a single rule throughout drops most of them fully.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What does `.T` do to a 1-D array of shape (3,)?

  2. Why is transposing a 1 GB array instant?

  3. You have an image array shaped (3, 64, 48) and need (64, 48, 3). What is the cleanest call?

  4. Why can `ravel()` on a transposed array return a copy?

Cheat sheet

Transpose and Moving Axes

Transpose a NumPy array and reorder its axes with swapaxes and moveaxis. See how the operation changes strides without copying any data.

NUMPY · vizlearn.in/numpy/transpose_and_axes.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.