When I Stop Reaching for List Comprehensions
I love comprehensions, but I have learned the three cases where they cost more than they save: nested filtering, side effects, and big intermediate lists. Here is the pattern I switch to in each.
By @ayomidegray
February 11, 2026
·
Updated May 18, 2026
628 views
4
3.9 (9)
from __future__ import annotations
# A comprehension I have actually shipped (and regretted):
# Rebuild a routing table by user permission level. Each row has nested data.
rows = [
{'user': 'alice', 'roles': ['admin', 'editor'], 'features': ['beta']},
{'user': 'bob', 'roles': ['viewer'], 'features': []},
{'user': 'carol', 'roles': ['editor'], 'features': ['beta', 'analytics']},
]
# DON'T: the conditions stack up and the predicate becomes unreadable.
bad = [r['user'] for r in rows if 'admin' in r['roles'] or ('editor' in r['roles'] and 'beta' in r['features'])]
print('bad:', bad)
# DO: name the predicate. The comprehension stays one line, the rule is testable.
def can_access_beta(row):
if 'admin' in row['roles']:
return True
if 'editor' in row['roles'] and 'beta' in row['features']:
return True
return False
good = [r['user'] for r in rows if can_access_beta(r)]
print('good:', good)The comprehension is not the problem; the predicate is. The moment a filter has more than one boolean operator I extract it into a named function whose name reads like business logic (can_access_beta). The win is double: the comprehension reverts to one obvious line (pluck users where can_access_beta), and the rule itself is now unit-testable in isolation. I have caught real bugs in this exact extraction step because seeing or ('editor' in r['roles'] and 'beta' in r['features']) written as a function exposes the precedence the inline version is hiding.
from __future__ import annotations
# Comprehensions are for building values. The moment you need to log, write
# to a file, or update a counter, switch to a for-loop and stop pretending.
requests = [
{'id': 1, 'status': 200, 'ms': 42},
{'id': 2, 'status': 500, 'ms': 880},
{'id': 3, 'status': 200, 'ms': 51},
{'id': 4, 'status': 503, 'ms': 410},
]
# DON'T: this works but it is a list comprehension used for its side effect.
# The list it returns ([None, None, None, None]) is garbage, and reviewers will
# stop trusting your taste.
_ = [print(f'slow: {r}') for r in requests if r['ms'] > 200]
# DO: the loop is one extra line, costs nothing, and signals 'this is a side effect'.
for r in requests:
if r['ms'] > 200:
print(f'slow: {r}')
# Mixed case: build a list AND log along the way. Still a loop. Two accumulators.
slow_ids = []
for r in requests:
if r['ms'] > 200:
print(f'slow: {r["id"]}')
slow_ids.append(r['id'])
print('slow_ids:', slow_ids)A comprehension that returns [None, None, ...] is a code smell I correct in every code review. The intent is wrong: comprehensions are for building a value, loops are for performing actions. Worse, the throwaway list briefly holds one entry per input, which is fine for 4 requests and a bug for 40 million. The mixed case (build a result AND log) is where teams sometimes try a clever walrus-operator comprehension; resist the urge. A four-line for-loop with two accumulators reads better than any expression you can fit in one line.
from __future__ import annotations
from typing import Iterator
import sys
# A 10-million-row pipeline: filter, transform, take first 5.
# The list-comprehension version materializes 10M rows of intermediates.
# The generator version computes only what the consumer pulls.
def rows() -> Iterator[dict]:
for i in range(10_000_000):
yield {'id': i, 'val': (i * 2654435761) & 0xffffffff}
# DON'T: builds a 10M-element list, then a 5M-element list, then takes 5.
# Peak memory is hundreds of MB.
# big = [r['val'] for r in rows() if r['val'] % 2 == 0][:5]
# DO: replace the outer brackets with parentheses. Now it is a generator;
# islice pulls 5, the rest is never computed.
from itertools import islice
first_five = list(islice((r['val'] for r in rows() if r['val'] % 2 == 0), 5))
print('first_five:', first_five)
# Why this matters in practice
print('size of first_five:', sys.getsizeof(first_five))The single-character change is [ ... ] to ( ... ), but the consequences are everything: a comprehension is eager and fully materializes, a generator expression is lazy and produces values on demand. Combined with itertools.islice you get early termination for free, so a [:5] slice that would have walked 10 million rows now walks until the fifth match. The rule I follow: if the input could conceivably be larger than memory, default to a generator and convert to a list at the call site that needs random access. The only reason to keep the eager comprehension is if you genuinely need the whole list and benefit from local random access.
