Community Article

List Comprehensions and When to Stop Using Them

Comprehensions are the fastest way to express a simple transform-and-filter, but they decay into write-only code the moment you nest them or sneak side effects in. Here is the line I draw.

List Comprehensions and When to Stop Using Them

Comprehensions are the fastest way to express a simple transform-and-filter, but they decay into write-only code the moment you nest them or sneak side effects in. Here is the line I draw.

py-list-comprehensions
py-comprehensions
py-generators
fundamentals
interview-prep
yunatorres

By @yunatorres

December 24, 2025

·

Updated May 20, 2026

829 views

20

4.3 (13)

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.

# good: simple map
upper_names = [name.upper() for name in users]

# good: simple filter
active = [u for u in users if u.is_active]

# good: map + filter
active_upper = [u.name.upper() for u in users if u.is_active]

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.

# what does this do?
pairs = [(x, y) for x in range(3) for y in range(3) if x != y]

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:

pairs = []
for x in range(3):
    for y in range(3):
        if x != y:
            pairs.append((x, y))

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.

# multi-filter (AND)
adults_with_email = [u for u in users if u.age >= 18 if u.email]

# conditional output (ternary inside the expression)
labels = ['adult' if u.age >= 18 else 'minor' for u in users]

The first one filters, the second one transforms. Mixing both in a single comprehension is where I usually lose readers, including future-me:

# don't do this
result = [
    u.name.upper() if u.is_admin else u.name
    for u in users
    if u.is_active and u.email
]

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:

result = []
for u in users:
    if not (u.is_active and u.email):
        continue
    result.append(u.name.upper() if u.is_admin else u.name)

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.

# do not do this
[print(u.name) for u in users]

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.

for u in users:
    print(u.name)

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.

# allocates a list of 10 million ints
total = sum([x * x for x in range(10_000_000)])

# allocates one int at a time, never materialises the list
total = sum(x * x for x in range(10_000_000))

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:

ShapeVerdict
Map / filter / map+filter on one iterableComprehension
Nested two iterables, both shortComprehension if it fits on one line
Nested two iterables, one long or named badlyfor loop
Filter + ternary in expressionfor loop
Side effect, value discardedfor loop
Result fed to a one-shot consumerGenerator expression
Result indexed, measured, or iterated twiceList 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.

Back to Articles