Reordering axes without moving a single byte - and the one case where .T silently does nothing.
Overview
Strides again
An array knows how many bytes to step for each axis. Transposing swaps those numbers along with the shape, and the underlying buffer is untouched.
That is why a.T on a gigabyte array returns instantly. It is a view, and np.shares_memory(a, a.T) confirms it.
Everything 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 is no second axis to swap, so the operation is meaningless and NumPy performs it silently rather than raising. People coming from MATLAB or from linear algebra notation expect a column vector and get their input back.
The fix is to add an axis: v[:, None] gives (3, 1), and v[None, :] gives (1, 3). reshape(-1, 1) does the same job.
This is also why an outer-style operation 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, because with three or more axes the interesting operation is usually moving one specific axis somewhere.
a.transpose(2, 0, 1) names the new order in terms of old positions: 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), and 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 would read them.
Transposing breaks that. The transposed view is F-contiguous instead — the same buffer, read column-first.
Most operations do not care. But anything that needs a flat contiguous buffer must copy:
ravel() returns a view when it can and a copy when it cannot. On a transposed array, it cannot.
reshape has the same behaviour, which is why "reshape returns a view" carries a *usually*.
Neither warns you. If it matters — because the array is large, or because you were relying on writing through the result — check with np.shares_memory, or force the issue with np.ascontiguousarray so the copy is explicit and happens where you can see it.
Transpose is not rotation
This trips people up on image data.
Transpose reflects along the main diagonal. Rotating a quarter turn is np.rot90. Mirroring is np.flipud (up-down) and np.fliplr (left-right).
They are related — a rotation is a transpose followed by a flip — but they are different operations, and reaching for .T to turn an image gives a mirrored result that looks almost right and is not.
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-ordered array is F-contiguous, because reading the transpose row by row is reading the original column by column. Nothing moved; the same bytes are simply described differently.
Both flags can be true at once for a 1-D array or an array with a length-1 axis, and both can be false for a strided slice like a[::2]. a.flags reports all of it.
Where this becomes practical: some LAPACK routines want Fortran order and will copy internally if they do not get it, and interfacing with Fortran or MATLAB-derived code sometimes requires np.asfortranarray. For everything else, staying in C order and not thinking about it is 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 removes it.
expand_dims(a, axis) is the explicit form of a[None], and reads better when the axis position is computed rather than literal.
squeeze with no argument removes every length-1 axis, which is the source of a genuinely nasty intermittent bug: a batch of one sample loses its batch dimension, so the code works for every batch size except one. Always name the axis — squeeze(axis=1) — in code that handles variable batch sizes.
einsum as general axis manipulation
Once an operation needs a transpose, two newaxis insertions and a sum over a particular axis, np.einsum usually expresses it more clearly.
The subscript string names an index for each axis of each operand and says which survive:
"ij->ji" is a transpose. "ii->i" extracts a diagonal. "ij->j" sums over rows. "ij,jk->ik" is matrix multiplication. "bij,bjk->bik" is batched matrix multiplication.
Indices that appear on the left but not the right are summed over. Indices repeated between operands are matched and multiplied.
It is not always fast — for plain matrix products, @ dispatches to a tuned BLAS routine and einsum may not. Its advantage is legibility: the string states the operation, where 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 several operands, where the order genuinely matters.
The batch-axis convention
Most numerical code follows one convention: the batch axis comes first, and 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 automatically, so a per-channel or per-feature parameter of shape (channels,) combines with the data without any index juggling. Put the channel axis first and the same operation needs [:, None, None].
matmul follows the same convention: it treats the last two axes as the matrix and broadcasts everything before them, so a (batch, n, k) @ (k, m) works directly.
Following the convention in your own arrays means broadcasting and matmul cooperate with you rather than requiring a transpose at every boundary.
In practice
Use .T freely on 2-D and never on 1-D.
Use moveaxis or swapaxes on higher dimensions, because they name what you meant and .T reverses everything.
Remember that all of them return views over the same bytes, so 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 somewhere else.
Diagonals and axis-aware helpers
np.diagonal(a) extracts the main diagonal and returns a view in modern NumPy — read-only, because writing to it has no consistent meaning across the strides involved.
np.trace(a) sums the diagonal without materialising it.
np.fill_diagonal(a, value) writes to the diagonal in place, which is the supported way to modify it. It is the standard step when building an adjacency or distance matrix that should have zeros on the diagonal.
offset on diagonal selects the super- or sub-diagonals, which is how you extract a band.
rollaxis, and why not to use it
Older code uses np.rollaxis, which moves an axis but with argument semantics that are genuinely hard to reason about — the destination is interpreted differently depending on direction.
np.moveaxis was added specifically to replace it, with the obvious semantics: source and destination, both plain positions.
If you meet rollaxis in existing code, translating it to moveaxis and checking the resulting shape is usually worth doing while you are there. If you are writing new code, there is no reason to use it.
Checking that a transform did what you meant
Axis manipulation is the easiest place in NumPy to produce 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 documents the transform better than a comment does.
Use distinguishable test data.np.arange(24).reshape(2, 3, 4) has a unique value in every position, so any misplacement is visible. An array of ones or of random values hides exactly the errors you are looking for.
This is worth doing because the failure mode — correct shape, permuted contents — produces 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, and silently a no-op on 1-D.
transpose(order) — explicit 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, and the right default for anything above 2-D.
expand_dims / squeeze — add and remove length-1 axes, with squeeze needing an explicit axis in any code that handles variable batch sizes.
einsum — when the index bookkeeping has become the substance of the operation.
All of them return views. That is what makes them free, and it is 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 somewhere visible.
A closing note on cost
Every operation in this module is free, and that is worth stating once more because it changes how you write code.
There is no reason to avoid a transpose, a moveaxis or an expand_dims on performance grounds. They adjust a handful of integers describing the array and return immediately, whatever its size.
The cost, when it comes, arrives later — at the point something needs contiguous memory and quietly copies. That is where to look when a pipeline is slower than the operations in it suggest, and a.flags plus np.shares_memory will tell you within a minute.
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 usually reveals that one moveaxis at the boundary replaces several scattered ones inside, and that keeping a single convention throughout removes most of them entirely.
Check yourself
0 of 4
Answer without scrolling back up.
What does `.T` do to a 1-D array of shape (3,)?
There is no second axis to swap, and NumPy does it silently rather than raising. Use `v[:, None]` for a column.
Why is transposing a 1 GB array instant?
The strides simply swap. `np.shares_memory(a, a.T)` is True - it is a view over the same bytes.
You have an image array shaped (3, 64, 48) and need (64, 48, 3). What is the cleanest call?
`.T` would give (48,64,3) by reversing everything, and reshape would scramble the data. moveaxis relocates one axis and slides the rest.
Why can `ravel()` on a transposed array return a copy?
The same reason `reshape` returns a view only *usually*. Check with np.shares_memory, or use np.ascontiguousarray to make the copy explicit.
Cheat sheet
Transpose and Moving Axes
An array knows how many bytes to step for each axis. Transposing swaps those numbers along with the shape, and the underlying buffer is untouched.
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.