Intuition
You will write the four sweeps, test it on a square matrix, watch it pass, and ship it. Then a 3x4 or a single-row matrix comes along and a whole row appears twice in your output. The reason is that you only check top <= bottom and left <= right at the top of the while loop, but the boundaries move three times inside one iteration. On a thin remainder the top sweep consumes the last row and pushes top past bottom, and the bottom sweep then re-reads that exact same row because nobody re-checked. The fix is not clever, it is disciplined: re-test top <= bottom immediately before the bottom sweep, and left <= right immediately before the left sweep. The invariant you are protecting is that the rectangle bounded by the four walls always contains exactly the cells not yet emitted, so a sweep is only legal while that rectangle is still non-empty.
Four shrinking boundaries rather than direction vectors and a visited grid. Each of the four passes consumes one edge and pulls its boundary inward. The two guards before the bottom and left passes are what stop a single remaining row or column being traversed twice.
Approach
Four walls, not four directions
Rather than tracking a heading and turning, keep four integers: top, bottom, left and right. They fence off the sub-rectangle that has not been read yet. Each of the four sweeps reads one full wall of that rectangle and then retracts its own boundary inward by one. Thinking in walls rather than turns is what makes the termination condition expressible at all: the spiral is finished exactly when the rectangle becomes empty.
Sweep, then retract, in a fixed order
Go left-to-right along the top row and increment top; top-to-bottom along the right column and decrement right; right-to-left along the bottom row and decrement bottom; bottom-to-top along the left column and increment left. Emitting before retracting matters, because the retraction is what marks those cells as consumed. Doing it in the other order would skip a wall on the first pass.
Re-check the walls mid-loop
This is the part everyone gets wrong. After the first two sweeps, top and right have already moved, so the loop condition tested at the top of the iteration is stale. Guard the bottom sweep with top <= bottom and the left sweep with left <= right. Without them, a matrix with a single remaining row or column emits that row or column twice, because the return sweep walks back over ground the outbound sweep already covered.
Solution & live demo
Common pitfalls
Omitting the guards on the bottom and left passes
for c in range(right, left - 1, -1):
out.append(matrix[bottom][c])
bottom -= 1if top <= bottom:
for c in range(right, left - 1, -1): ...On a single-row matrix, the top pass already consumed it and moved top past bottom. Without the guard the bottom pass re-emits the same row backwards, duplicating every value.
Using a visited matrix and direction turning
dirs = [(0,1),(1,0),(0,-1),(-1,0)] # turn when blocked or visited
while top <= bottom and left <= right:
That works but needs O(m·n) extra space and careful turn logic. The boundaries encode the same information in four integers, and the traversal order is explicit rather than emergent.
Checking only one boundary pair in the loop condition
while top <= bottom:
while top <= bottom and left <= right:
Rows and columns are exhausted at different times on non-square matrices. Testing only one pair lets the passes run with an inverted range on the other axis, emitting nothing or looping.
Edge cases
Return an empty list before entering the loop; matrix[0] would otherwise raise.
The top sweep takes everything and pushes top past bottom; the mid-loop guard skips the bottom sweep so nothing is duplicated.
Top then right sweeps take everything and right falls below left; the left-sweep guard prevents a second pass.
Rows and columns exhaust at different times, so the two guards fire independently rather than together.