You loaded an image, got a tensor shaped (batch, height, width, channels), and your convolution wants (batch, channels, height, width). Stack Overflow says permute. Someone else says transpose. And reshape(2, 3, 28, 28) gives you the right shape too — so why is everyone making this complicated?
Because two of those three are the same tool, and the third one silently destroys your data.
The short answer
transpose(dim0, dim1) swaps exactly two dimensions. permute(...) reorders all of them in one call, and you must list every dimension. transpose is a special case of permute. Both return a view — no data is copied, only the strides change — which also means both leave you with a non-contiguous tensor.
reshape is not in this family at all. It reinterprets the flat memory under a new shape without moving anything, so it can produce the shape you asked for while completely scrambling what the numbers mean.
import torch
t = torch.arange(24).reshape(2, 3, 4)
print(t.transpose(0, 1).shape) # torch.Size([3, 2, 4]) — swapped dims 0 and 1
print(t.permute(2, 0, 1).shape) # torch.Size([4, 2, 3]) — full reorder
Enter fullscreen mode Exit fullscreen mode
transpose — swap two axes
transpose(dim0, dim1) takes two dimension indices and swaps them. Everything else stays put.
t = torch.arange(24).reshape(2, 3, 4)
print(t.shape) # torch.Size([2, 3, 4])
print(t.transpose(0, 1).shape) # torch.Size([3, 2, 4])
print(t.transpose(1, 2).shape) # torch.Size([2, 4, 3])
Enter fullscreen mode Exit fullscreen mode
The order of the two arguments doesn't matter — t.transpose(0, 1) and t.transpose(1, 0) are the same thing. A swap is a swap.
On a 2-D tensor this is the matrix transpose you already know, and .T is the shorthand:
m = torch.arange(6).reshape(2, 3)
print(m.T.shape) # torch.Size([3, 2])
print(m.transpose(0, 1).shape) # torch.Size([3, 2]) — identical
Enter fullscreen mode Exit fullscreen mode
One caution on .T: on tensors with more than two dimensions, .T reverses every dimension, and modern PyTorch has deprecated that behaviour — it warns now and is slated to become an error. If you want the "transpose the last two axes" behaviour on a batch of matrices, use .mT, which is explicit and safe:
t = torch.arange(24).reshape(2, 3, 4)
print(t.mT.shape) # torch.Size([2, 4, 3]) — swaps the last two dims, batch untouched
Enter fullscreen mode Exit fullscreen mode
Reserve plain .T for 2-D matrices. On anything higher, say what you mean with permute or .mT.
permute — state the whole new order
permute doesn't swap; it rewrites the dimension order in full. You pass the old index of each dimension in the position you want it to end up.
t = torch.arange(24).reshape(2, 3, 4)
print(t.permute(2, 0, 1).shape) # torch.Size([4, 2, 3])
Enter fullscreen mode Exit fullscreen mode
Read permute(2, 0, 1) as: "new dim 0 is old dim 2, new dim 1 is old dim 0, new dim 2 is old dim 1." Not "move dim 2 somewhere" — you are writing out the destination order, left to right.
Two consequences worth internalising:
- You must list every dimension. Miss one and you get
RuntimeError: number of dims don't match in permute. - Any reorder you can do with a chain of transposes, you can do in one
permute:
t = torch.arange(24).reshape(2, 3, 4)
via_permute = t.permute(1, 2, 0) # torch.Size([3, 4, 2])
via_transposes = t.transpose(0, 1).transpose(1, 2) # torch.Size([3, 4, 2])
print(torch.equal(via_permute, via_transposes)) # True
Enter fullscreen mode Exit fullscreen mode
Same result. The permute version says the destination shape out loud; the chained version makes the reader simulate two swaps in their head. Prefer permute for anything beyond a single swap.
The one that actually bites: permute is not reshape
Here is the bug this article exists for. You have a batch of images in (N, H, W, C) — the layout you get from OpenCV, PIL, TensorFlow, and most image files — and PyTorch convolutions need (N, C, H, W).
Both of these produce a tensor of the right shape:
img = torch.arange(1 * 2 * 2 * 3).reshape(1, 2, 2, 3) # N=1, H=2, W=2, C=3
print(img.permute(0, 3, 1, 2).shape) # torch.Size([1, 3, 2, 2]) ✅
print(img.reshape(1, 3, 2, 2).shape) # torch.Size([1, 3, 2, 2]) ⚠️ same shape!
Enter fullscreen mode Exit fullscreen mode
Same shape. No error. No warning. Now look at what's actually inside the first channel:
print(img.permute(0, 3, 1, 2)[0, 0])
# tensor([[0, 3],
# [6, 9]]) ← the red value of each of the 4 pixels ✅
print(img.reshape(1, 3, 2, 2)[0, 0])
# tensor([[0, 1],
# [2, 3]]) ← pixel 0's R, G, B and then pixel 1's R ❌
Enter fullscreen mode Exit fullscreen mode
The permute version collected the red channel: pixels are stored as (R,G,B)(R,G,B)…, so the reds are elements 0, 3, 6, 9. That's a real red channel.
The reshape version just took the first four numbers in memory and called them "channel 0" — that's one whole pixel plus a third of the next one. Your "red channel" is now a mixture of red, green and blue from different pixels. The tensor is the right shape, the model trains, the loss goes down a little, and the accuracy is quietly terrible. Nothing on screen ever tells you.
The rule:
permutemoves dimensions.reshapere-cuts the same flat sequence of numbers into new brackets. If you want to change what an axis means, you needpermute— always.
If you want the full picture on how reshape re-cuts memory, that's covered in Reshape vs View in PyTorch.
Real code: the two reorders you'll actually write
Images — channels-last to channels-first:
img = torch.randn(2, 28, 28, 3) # N, H, W, C (from PIL / OpenCV)
x = img.permute(0, 3, 1, 2) # N, C, H, W (what nn.Conv2d wants)
print(x.shape) # torch.Size([2, 3, 28, 28])
Enter fullscreen mode Exit fullscreen mode
Attention — splitting into heads. This is the one place where reshape and permute correctly appear back-to-back, and seeing why makes the distinction click:
B, S, E, H = 2, 5, 8, 2 # batch, seq_len, embed_dim, heads
x = torch.randn(B, S, E) # torch.Size([2, 5, 8])
x = x.reshape(B, S, H, E // H) # torch.Size([2, 5, 2, 4]) — split, don't move
x = x.permute(0, 2, 1, 3) # torch.Size([2, 2, 5, 4]) — move heads forward
Enter fullscreen mode Exit fullscreen mode
The reshape is legitimate here because it only splits the last axis — the 8 embedding values were already laid out contiguously, so cutting them into 2 groups of 4 doesn't reorder anything. The permute then genuinely moves the head axis in front of the sequence axis. Splitting an axis is reshape work; moving an axis is permute work.
Both of them break contiguity
transpose and permute never touch the underlying data. They change the tensor's strides — the step size PyTorch takes through memory for each dimension:
a = torch.arange(6).reshape(2, 3)
print(a.stride(), a.is_contiguous()) # (3, 1) True
b = a.permute(1, 0)
print(b.shape, b.stride(), b.is_contiguous()) # torch.Size([3, 2]) (1, 3) False
Enter fullscreen mode Exit fullscreen mode
That's why they're free. It's also why the very next .view() you call will blow up:
b.view(6)
# RuntimeError: view size is not compatible with input tensor's size and stride
# (at least one dimension spans across two contiguous subspaces). Use .reshape(...) instead.
Enter fullscreen mode Exit fullscreen mode
Two fixes, and the choice matters:
b.reshape(6) # works — copies quietly when it has to
b.contiguous().view(6) # works — reorders memory first, then views
Enter fullscreen mode Exit fullscreen mode
Use .reshape() by default. Reach for an explicit .contiguous() when you're about to do many operations on the permuted tensor and want the memory laid out well once, up front, rather than copied repeatedly.
Common mistakes and gotchas
-
Using
reshapeto convert NHWC → NCHW. The headline bug. Right shape, scrambled channels, no error. Usepermute(0, 3, 1, 2). -
Reading
permute(2, 0, 1)as instructions to move things. It's a destination list: new dim i is old dimargs[i]. Say it out loud before you trust it. -
Forgetting a dimension.
permuteneeds all of them →RuntimeError: number of dims don't match in permute. -
Using
.Ton 3-D+ tensors. Deprecated (it reverses all dims, which is almost never what you want). Use.mTfor a batch of matrices, orpermuteto say it exactly. -
Calling
.view()right after a permute. Guaranteed contiguity error..reshape()or.contiguous().view(). -
Assuming a transpose changed your data. It changed the strides only.
print(x.stride())next toprint(x.shape)when a shape bug won't make sense — the strides usually tell you the real story.
Recap
transpose(a, b) swaps two dimensions; permute(...) rewrites the whole dimension order and needs every index listed. They do the same kind of work, and permute is the one to reach for whenever more than one swap is involved. Both are free view operations that leave the tensor non-contiguous, so the next .view() will error — use .reshape().
And the part that costs people a weekend: reshape is not a reordering tool. It can hand you a correctly-shaped tensor full of scrambled values, with nothing on screen to warn you. When an axis needs to mean something different, permute it.
More in this series: Reshape vs View · Broadcasting Explained · What unsqueeze Does · What keepdim Does
A question for you: what's the shape bug that cost you the most time? I'm collecting the ones that hit hardest — drop it in the comments and I'll write up the fix.
This is one idea from my book PyTorch From Ground Up, which builds everything from tensors upward so nothing stays vague. If it helped, you can grab the free PyTorch tensor cheat-sheet here, run every example from the book in the companion notebooks on GitHub, get the digital edition on Leanpub, or find the full paperback on Amazon here.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.