Comprehensions shine when a single expression turns an iterable into a new collection. They turn awkward when that expression starts hiding real work — a nested loop, a side effect, an early exit, or a function call in every element. This lesson is about recognising the moment a plain for loop becomes the clearer, faster tool.
When does a comprehension stop being clearer?
A comprehension works well when three things are true: you are transforming each element into exactly one new value, the transformation is a single expression, and you do not need to stop halfway. The moment one of those breaks, the syntax starts costing you more than it saves. Reading [f(x) for x in data] is instant; reading [f(x) for x in data if g(x) and h(x) > 0] takes a beat, and reading a nested comprehension over two iterables takes a beat and a half.
The practical rule: a comprehension should fit in your head as a single sentence. "For each item, keep the ones that pass this test, and turn them into that value." When you need more than one clause of that sentence, write the loop.
When a nested loop hides inside a comprehension
The clearest example of a comprehension doing too much is a nested one. Here is a nested comprehension that flattens pairs of coordinates into a list of "ax,by" strings, followed by the version most readers find easier to maintain.
# Nested comprehension — one line, three things happening at once
labels = [f"{a}x{b}y" for a in (1, 2) for b in (10, 20)]
print(labels)
# Explicit nested loop — the same result, but each step is visible
labels = []
for a in (1, 2):
for b in (10, 20):
labels.append(f"{a}x{b}y")
print(labels)
Both print ['1x10y', '1x20y', '2x10y', '2x20y']. The loop version is longer, and it is still the better tool the moment you need to skip a pair, count matches, or break out early. A comprehension has no break — if you have ever wanted to stop halfway, you have already left comprehension territory.
Side effects do not belong in a comprehension
Some code ends up doing work inside a comprehension that it should have done outside. A validation function that also logs, or a transform that also writes to a cache, turns a pure transformation into something with side effects — and the side effect now runs once per element, invisibly.
seen = []
def normalise_and_record(value):
cleaned = value.strip().lower()
seen.append(cleaned) # a side effect hidden inside a transform
return cleaned
names = [" Ada ", "GRACE", " Alan "]
cleaned = [normalise_and_record(n) for n in names]
print(cleaned)
print(seen)
It works, and it prints ['ada', 'grace', 'alan'] followed by the recorded list. But now every reader has to know what normalise_and_record secretly does before they can reason about the comprehension. When a transform has a side effect, write the loop and make the side effect explicit.
map() and filter() are not automatically faster
It is a common belief that map() and filter() are faster than comprehensions because they look more low-level. With a built-in function they can be, because the call happens in C. With a Python-level function, map() adds a wrapper call per element, and a comprehension that inlines the same work is usually quicker. The difference is small, so measure instead of guessing.
from timeit import timeit
data = list(range(100_000))
def double(x):
return x * 2
print("map with a Python function:",
timeit(lambda: list(map(double, data)), number=5))
print("comprehension: ",
timeit(lambda: [double(x) for x in data], number=5))
print("comprehension inlined: ",
timeit(lambda: [x * 2 for x in data], number=5))
Run it on your own machine and compare the three numbers. The inlined comprehension is the fastest of the three on any modern CPython, and the gap between map and the comprehension is the price of the extra function call. Neither difference is large enough to matter for a list of 100 items — which is exactly why the readability argument usually wins.
When you need to stop early
Search code is where comprehensions most often mislead. Building a whole list just to look at the first match does work you did not need. Generator expressions with any() and all() short-circuit, which means they stop as soon as the answer is known.
numbers = [3, 7, 11, 4, 18, 25]
# List comprehension: every element is evaluated, then you look at the result
big = [n for n in numbers if n > 10]
print(big)
# Generator + any(): stops at the first match
print(any(n > 10 for n in numbers))
# Generator + all(): stops at the first failure
print(all(n % 2 == 0 for n in [2, 4, 6, 7, 8]))
The second line prints True without checking the remaining numbers, and the third prints False as soon as it reaches 7. If you only need a yes-or-no answer, the generator form is both clearer and cheaper.
Picking the right tool
Here is the decision you can reuse: one transformation, one expression, no early exit, no side effects — write the comprehension. Anything more than that — multiple statements, a nested loop, a break, a side effect, a condition worth explaining — write the loop. A loop is not the outdated option; it is the option with more room to say what you mean. Reach for the comprehension when it makes the code shorter and clearer at the same time, which is rarer than the tutorials suggest.
Quick reference
- Comprehension: one expression per element, no branching, no side effects, no early exit.
- Loop: multiple statements,
breakorcontinue, counters, accumulating results. - Generator expression: when you want lazy evaluation, short-circuiting, or a stream of values.
any()andall(): when the answer is a boolean, not a list.- When in doubt, write the loop first. It is easy to convert a loop into a comprehension afterwards; the reverse is not true.
Try it yourself: take the nested coordinate comprehension from the first section and rewrite it so that it skips any pair where a == b, printing how many pairs were kept. Then convert the same problem to a comprehension that does not skip anything and compare the two outputs. Finally, time both versions with timeit over 100,000 pairs and note the difference.