The famous one-liner, taken apart. The star is the whole trick, and zip’s default behaviour is the whole hazard.
rows = [[1, 2, 3], [4, 5, 6]] zip(rows) # one argument: pairs of nothing -> (([1,2,3],), ...) zip(*rows) # same as zip([1,2,3], [4,5,6]) -> (1,4) (2,5) (3,6) list(zip(*rows)) # [(1, 4), (2, 5), (3, 6)] - tuples, not lists # And the hazard list(zip(*[[1, 2, 3], [4, 5]])) # [(1, 4), (2, 5)] # the 3 is gone, and nothing was raised
- *rows spreads the list into separate arguments
zip(rows) passes one argument — the whole list of rows — and zips it with nothing. zip(*rows) passes each row as its own argument, which is what makes zip take one item from each row per step. The star at a call site is the mirror image of *args in a definition: one gathers, the other spreads.
- zip stops at the shortest and does not mention it
Given a ragged table, zip produces the number of columns of the shortest row and drops the rest without a word. That is the right default for pairing streams of unknown length and the wrong one for a table, where a short row means the data is broken. Checking the lengths yourself — or passing strict=True — turns silent loss into an error.
- zip yields tuples, and the contract asked for lists
The result of zip is tuples, which are fine to iterate and wrong if the caller intends to append to a column. list(column) inside the comprehension is a deliberate conversion, not decoration — and it is the kind of detail that only shows up when somebody two files away tries to mutate what you returned.
Check that the table is rectangular before you transpose it, because the transposing step will not check for you. Then look up what the * does in front of an argument at a call site.
A set of the row lengths has one element when they all agree — and zero when there are no rows at all, which is also fine. zip(*rows) does the actual work; remember it yields tuples.
if len({len(row) for row in rows}) > 1: raise ValueError. Then return [list(column) for column in zip(*rows)]. On Python 3.10 and later, zip(*rows, strict=True) raises for you — but only once it reaches the mismatch, so the message is less direct.
def transpose(rows): widths = {len(row) for row in ____} if len(widths) > ____: raise ____(f’rows have different lengths: {sorted(widths)}') return [____(column) for column in zip(____rows)]
def transpose(rows):
widths = {len(row) for row in rows}
if len(widths) > 1:
raise ValueError(
f"rows have different lengths: {sorted(widths)}"
)
return [list(column) for column in zip(*rows)]
All equal means one distinct length; no rows means none. Both are 'not more than one', so a single comparison covers the valid cases and the empty one together. It also gives the error message something useful to say — sorted(widths) tells the caller which lengths are in the table, which is most of what they need to find the bad row.
zip is lazy and truncating, so by the time the comprehension has finished, the evidence of the problem is gone — you cannot look at the output and tell whether a column was dropped. Validate the input while you still have it. Any time a library function’s failure mode is 'silently produce less', the guard belongs in front of it.
The star unpacks every row into a separate argument, so a table with a million rows becomes a call with a million arguments. It works, and it is memory the caller has already paid for, but it is worth knowing before you point it at something huge. numpy’s .T does the same job by swapping strides — no copying and no unpacking at all.
Every version below is correct and verified against the same cases by the build. They are here because knowing which one to reach for is the part that takes judgement, and the trade-off is stated rather than left for you to guess.
def transpose(rows):
return [list(column) for column in zip(*rows, strict=True)]
Python 3.10 and later. strict=True makes zip raise ValueError instead of truncating, which is exactly the guard, written by the standard library. Two things to know: the error arrives partway through, once zip reaches the ragged edge, so its message names no lengths and any work already done is discarded — and the keyword silently does nothing on older Pythons that do not have it. Prefer it in new code; keep the explicit check when the message matters.
For real numeric work use numpy: array.T is instant, because it changes how the data is read rather than moving any of it. For tabular data with named columns, pandas already keeps both orientations available and .T is a method call. And if you are transposing to compute one thing per column — a sum, a maximum — you probably do not need the transpose at all: zip(*rows) inside the comprehension that consumes it avoids building the intermediate table entirely.
- Pad short rows with a fill value instead of raising, using itertools.zip_longest, and decide when that is a kindness and when it is hiding a broken export.
- Transpose in place for a square table, without building a second one, and see how much harder it is to read.
- Check that transpose(transpose(rows)) == rows for every case here that does not raise.