I rewrote the same line of Python four times last week before I gave up and turned it back into a for loop. The original was a list comprehension I had written six months earlier, doing a flatten plus a filter plus a transform plus a conditional default. It was technically one line, technically correct, and completely unreadable. The for loop replacement was eight lines long and I understood it on first read. That is the trade I want to talk about.
The argument I want to make is simple: list comprehensions are the right default for one-liner transforms, but they have a readability cliff and most Python codebases I have worked in fall off it regularly. The cure is to know which shape of comprehension is still readable, and to switch to a for loop or a generator the moment you cross the line. "Pythonic" is not a synonym for "crammed into one expression".
The shape that is always fine
The canonical comprehension is map plus filter on a single iterable. This shape is unambiguously easier to read than the loop equivalent.
Each of these reads left-to-right like English: "the upper-cased name for each user, where the user is active". The loop equivalent is three or four lines, has a temporary list and an explicit append, and adds nothing. Use the comprehension. Stop reading articles that tell you to rewrite these as for loops for clarity. They are clearer as comprehensions.
The first warning sign: nesting
The shape gets harder to read fast when you nest two iterables. The order of the for clauses is left-to-right (outer-to-inner), which is the opposite of what most people read at first.
The outer loop is x, the inner loop is y. It produces every ordered pair where the values differ. Reading this correctly takes a second pass even when you have written hundreds of them. The for loop version is more verbose but reads in one pass:
My rule for nested comprehensions: one nested level is fine if the iterables are small and clearly named. Two nested levels is almost always worth a for loop. Three is always wrong, no matter what your linter thinks.
The second warning sign: nested conditions
A comprehension can have multiple if clauses, and a conditional expression inside the output. These get confused regularly.
The first one filters, the second one transforms. Mixing both in a single comprehension is where I usually lose readers, including future-me:
The expression part has its own ternary, the filter part has its own boolean. Two cognitive contexts in one expression. I read this code, I copy it, I run it, I never quite trust it. The same logic in a for loop is plain:
The for loop wins because it lets each operation have its own line and its own breathing room. Comprehensions punish you the moment you need more than one operation per element.
The third warning sign: side effects
A comprehension that performs an action and discards the result is the worst shape. Python lets you write it, the linter mostly does not catch it, and code review usually does.
This allocates a list of None values and throws it away. The intent was a side effect (printing), and the reader has to infer that from the missing assignment. The right shape is a for loop.
The rule: if the comprehension's value is not used, it should not be a comprehension. Use a for loop. The same applies to comprehensions that mutate state ("[items.append(x) for x in source]") which I have seen in production code more than I would like.
When a generator beats a list comprehension
A list comprehension materialises the whole result in memory. If you only need to iterate over the result once, a generator expression is strictly better: same syntax, parentheses instead of brackets, lazy evaluation.
The second line uses a fraction of the memory and is barely slower (the per-element overhead is similar). For any consumer that takes an iterable (sum, any, all, min, max, ''.join, file writes, database batch writes) the generator form is the right call.
A list comprehension is the right call when you need the materialised list: indexing, length, multiple passes, passing it to a function that calls len() or iterates twice. Default to the list comprehension only if you actually need the list.
The line I draw in code review
Put all of this together and the rule is roughly:
| Shape | Verdict |
|---|---|
| Map / filter / map+filter on one iterable | Comprehension |
| Nested two iterables, both short | Comprehension if it fits on one line |
| Nested two iterables, one long or named badly | for loop |
| Filter + ternary in expression | for loop |
| Side effect, value discarded | for loop |
| Result fed to a one-shot consumer | Generator expression |
| Result indexed, measured, or iterated twice | List comprehension |
In practice my heuristic is even simpler. If I cannot read the comprehension out loud as a single sentence in English, I rewrite it as a for loop. "The upper-cased name for each active user" is one sentence. "The upper-cased name if the user is admin else the name, for each user, where the user is active and has email" is not one sentence; it is three nested clauses pretending to be one.
What the rule actually buys you
The payoff for following this rule is not a measurable performance win. It is the version-control diff six months from now when someone needs to add a new condition. With a clean comprehension, the change is one short edit. With a tangled comprehension, the change is a rewrite, and the reviewer has to verify that the old and new forms behave identically. With a for loop, the change is a new line in the loop body and the diff tells the truth about what changed.
Readability is the only reason comprehensions exist as a syntactic feature in the first place; nothing they do cannot be done with a for loop and a list. The moment a comprehension is harder to read than the loop, its only justification is gone, and the only thing left is the urge to feel clever. Resist it. Pythonic code is the code that the next person can change in five minutes, not the code that compresses six operations into one line.
