Run it
Best-of-five timings, because these are noisy, and then the per-iteration bytecode of each form so the cause is visible rather than asserted.
Best of five runs, because a single measurement here is a coin flip.
And the reason, which is visible rather than asserted.
And the version that stores nothing - note what it does and does not buy.
The difference, in bytecode
out.append(x) is three separate things: find the attribute append on the list, build a call, and make it. That happens once per item. The comprehension compiles the append into LIST_APPEND, a single instruction that pushes the value onto the list being built — no name to resolve and no call to make.
The explorer above is those two instruction sequences, taken from dis rather than described, so you can count the difference. It is a handful of opcodes per item.
Why the number is smaller than people expect
Folklore puts comprehensions at two or three times faster, and that has not been true for a while. Two things narrowed it.
The specialising interpreter added in 3.11 rewrites hot instruction sequences in place, and a repeated attribute-lookup-then-call on the same type is exactly the pattern it specialises, so the loop's overhead shrank.
And in 3.12 comprehensions were inlined (PEP 709). Before that, a list comprehension created and called a hidden function object on every evaluation — real overhead that partly cancelled the LIST_APPEND saving. Removing it made comprehensions faster in a way that has nothing to do with the append path.
So the answer to give is "a bit faster, because of a per-iteration lookup and call", not a multiplier. The editor below measures it on whichever interpreter you are running.
When the loop is the right answer anyway
A comprehension has to be one expression. The moment the body needs a statement — a try, a break, a log line, two things per item — the loop is the only option, and forcing a comprehension produces the unreadable nested version everybody has met.
The real decision is readability at a glance. One transformation and one filter reads better as a comprehension; three levels of nesting with two conditions does not, whatever it costs. And if the result is only going to be consumed once, the better answer is often neither: a generator expression skips building the list at all. That is a memory win rather than a speed one — the editor below measures the two as roughly equal in time — but constant memory against a list of a million items is a larger practical difference than the constant factor this page is about.
The follow-up: what about map and filter
map(f, xs) can beat a comprehension when f is already a function, because it avoids a Python-level call per item by doing the loop in C — but map(lambda x: x * x, xs) is usually slower than the comprehension, because the lambda reintroduces exactly the per-item Python call you were trying to avoid. So map(str, xs) is a reasonable choice and map with a lambda rarely is.
What to say out loud
A bit faster. The loop does an attribute lookup and a function call per item; the comprehension uses a dedicated LIST_APPEND opcode with neither. It is a constant factor, so I would choose between them on readability, and reach for a generator expression when I do not need the list in memory - which saves the memory, not the time.
Then stop. The commonest failure on a question like this is answering it correctly in one sentence and then talking for another minute until something wrong comes out.
What to notice while it runs
- The loop's per-iteration bytecode contains
LOAD_ATTR and CALL; the comprehension's does not.
- Both forms are O(n) — the difference is per-item overhead.
- The measured gap is modest, which is the honest answer to the question.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
The measurement is noisy. The same code timed twice can differ by half, which is why the editor takes the best of five runs - and why quoting a single-run figure in an interview is a claim you cannot defend.
A comprehension over a huge input still builds the whole list. The speed argument is per item; the memory cost is the whole thing, and for a single pass a generator expression removes it.
Readability wins at the point where you have to re-read it. Two for clauses and a condition is usually the limit, and volunteering that boundary is a better answer than defending comprehensions in general.
The follow-ups interviewers ask
"Do dict and set comprehensions work the same way?" Yes, with MAP_ADD and SET_ADD in place of LIST_APPEND. Same saving, same reasoning.
"In a nested comprehension, what order do the for clauses run in?" Left to right, outermost first - the same order you would write the nested loops. [x for row in grid for x in row] flattens; swapping the clauses is a NameError, which is a quick way to check yourself.
"Does a comprehension leak its loop variable?" Not in Python 3 - it has its own scope, so i does not survive. It did leak in Python 2, and the fix is worth knowing because PEP 709 in 3.12 inlined comprehensions again without reintroducing the leak.
Common wrong answers
"Comprehensions are three times faster." Not on a modern interpreter. The specialising interpreter in 3.11 narrowed the gap and the honest figure is a small constant factor - which the editor above measures on whatever version you are running.
"They run in C, so they are parallel." Neither half is true. It is the same bytecode interpreter, one item at a time.
"Always prefer a comprehension." Not when the body needs a statement, and not when the nesting makes it unreadable. Volunteering that limit is usually what the question is actually probing.
Recap in one screen
- The loop's per-iteration bytecode contains LOAD_ATTR and CALL; the comprehension's does not.
- Both forms are O(n) - the difference is per-item overhead.
- The measured gap is modest, which is the honest answer to the question.
- The one-line answer: Yes, by a little, and for a specific reason: the loop looks up out.
- Worth trying: Hoist the lookup out of the loop with append = out.append and re-measure. This is classic advice and the result may surprise you on a modern interpreter - measure rather than assume.
- Worth trying: Compare map(str, range(N)) against [str(i) for i in range(N)], then against map(lambda i: str(i), ...). The lambda undoes the advantage.