Comprehensions and Generators: Building Sequences Without Building Them
Published:
for loop that produces a list, dict or set โ filtering with a trailing if, choosing values with a leading conditional expression. Swapping the brackets for parentheses gives a generator expression, which computes nothing until iterated and holds only one item at a time. A function containing yield becomes a generator function: calling it returns a paused generator rather than running the body. Both implement the iterator protocol, __iter__ plus __next__, which is what for actually talks to.The comprehension forms
print([x * x for x in range(6)]) # -> [0, 1, 4, 9, 16, 25]
print([x * x for x in range(10) if x % 2 == 0]) # -> [0, 4, 16, 36, 64]
The trailing if filters. To choose between values rather than drop them, use a conditional expression at the front โ a different construct in a different position:
print([x if x % 2 else -x for x in range(5)]) # -> [0, 1, -2, 3, -4]
Dict and set comprehensions use braces, distinguished by the presence of a colon:
print({x: x * x for x in range(4)}) # -> {0: 0, 1: 1, 2: 4, 3: 9}
print(sorted({c for c in "mississippi"})) # -> ['i', 'm', 'p', 's']
print({v: k for k, v in {"a": 1, "b": 2}.items()}) # -> {1: 'a', 2: 'b'}
Nesting has two distinct shapes and they are easy to confuse. Multiple for clauses read left to right like nested loops and produce a flat result; a comprehension inside a comprehension produces a nested one:
print([(r, c) for r in range(2) for c in range(3)])
# -> [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]
print([[r * c for c in range(3)] for r in range(2)])
# -> [[0, 0, 0], [0, 1, 2]]
Flattening is the most useful case of the first shape:
print([y for row in [[1, 2], [3, 4]] for y in row]) # -> [1, 2, 3, 4]
Comprehensions have their own scope, so the loop variable does not leak into the enclosing function โ one of the changes Python 3 made over Python 2.
When they hurt. A comprehension earns its place when it maps and filters. It stops earning it when the expression needs a comment, when there are three or more for clauses, when the body has side effects (a comprehension executed only for its side effects is a loop written badly), or when you find yourself using the walrus operator to smuggle in a temporary. The test is whether a reader can state what the result contains after one pass. If not, write the loop.
Generator expressions
Replace the brackets with parentheses and you get a lazy object. Nothing runs until something asks for an item:
g = (x * x for x in range(5))
print(g) # -> <generator object <genexpr> at 0x...>
print(next(g), next(g)) # -> 0 1
print(list(g)) # -> [4, 9, 16] the first two are already consumed
A generator is single-pass: once exhausted it is finished, and iterating it again yields nothing. That is the price of not storing the results.
When a generator expression is the only argument to a function, the parentheses can be omitted:
print(sum(x * x for x in range(5))) # -> 30
print(any(x > 3 for x in range(5))) # -> True
print(next((l for l in ["a", "", "b"] if l), None)) # -> a
The last line is a useful idiom: find the first match, or None, without scanning the rest.
The memory difference, measured
One million elements, allocation tracked with tracemalloc on CPython 3.13:
import tracemalloc
tracemalloc.start()
base = tracemalloc.get_traced_memory()[0]
lst = [x * 2 for x in range(1_000_000)]
print(tracemalloc.get_traced_memory()[0] - base) # -> about 40_400_000
| Expression | Allocated | Why |
|---|---|---|
[x * 2 for x in range(1_000_000)] | 40.4 MB | 8.4 MB of pointers, plus a million separate int objects |
(x * 2 for x in range(1_000_000)) | 432 bytes | one generator object holding a paused frame |
Roughly a factor of \(10^5\), and it is a factor of \(n\) in general: the list is \(O(n)\) in memory, the generator \(O(1)\). Total work is identical if you consume everything โ laziness saves memory, not instructions, and it saves time only when you stop early.
So: use a list when you need indexing, len, or more than one pass. Use a generator for a single streaming pass, for data too large to hold, or for a sequence with no end.
yield and generator functions
Any def whose body contains yield becomes a generator function. Calling it does not run the body; it returns a generator. Each next() runs to the following yield, hands back that value, and freezes the frame โ locals, instruction pointer and all โ until the next request.
def countdown(n):
while n > 0:
yield n
n -= 1
return "done"
print(list(countdown(3))) # -> [3, 2, 1]
c = countdown(3)
print(next(c), next(c), next(c)) # -> 3 2 1
next(c)
# StopIteration: 'done'
return inside a generator does not produce a value to the caller; it stops iteration, and its argument is attached to the StopIteration exception (e.value), which is mostly of interest when delegating with yield from.
Because state is preserved rather than accumulated, a generator can be infinite:
import itertools
def fib():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
print(list(itertools.islice(fib(), 10)))
# -> [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
yield from iterable delegates to another iterable, forwarding each of its items โ the clean way to compose generators or recurse over a tree:
def gen():
yield from [1, 2]
yield 3
print(list(gen())) # -> [1, 2, 3]
The iterator protocol
for is sugar over two calls. An iterable implements __iter__, which returns an iterator; an iterator implements __next__, returning the next item or raising StopIteration. An iterator must also implement __iter__ returning itself, so that it can be used in a for loop directly.
class Countdown:
def __init__(self, n):
self.n = n
def __iter__(self):
return self
def __next__(self):
if self.n <= 0:
raise StopIteration
self.n -= 1
return self.n + 1
print(list(Countdown(3))) # -> [3, 2, 1]
The distinction is exactly why a list can be looped over repeatedly and a generator cannot: iter(xs) on a list returns a fresh iterator every time, whereas a generator is its own iterator and hands back itself.
xs = [1, 2, 3]
print(iter(xs) is iter(xs)) # -> False two independent cursors
i1 = iter(xs)
print(iter(i1) is i1) # -> True an iterator returns itself
Generator functions are simply a far shorter way to write that class: the yield statement generates the __next__ machinery, the paused frame is the state, and falling off the end raises StopIteration for you.
itertools highlights
Every one of these returns a lazy iterator, so they compose without materialising intermediates.
| Function | Result |
|---|---|
chain(a, b) | [1, 2, 3] from [1, 2] and [3] โ concatenate iterables |
islice(it, n) | first n items; the only safe way to truncate an infinite generator |
count(10, 5) | 10, 15, 20, ... unbounded arithmetic sequence |
cycle(it) | repeat an iterable forever |
product([1, 2], "ab") | [(1,'a'), (1,'b'), (2,'a'), (2,'b')] โ the nested loop |
combinations([1,2,3], 2) | [(1,2), (1,3), (2,3)] |
permutations([1,2,3], 2) | [(1,2), (1,3), (2,1), (2,3), (3,1), (3,2)] |
accumulate([1,2,3,4]) | [1, 3, 6, 10] โ running totals |
groupby("aaabbc") | [('a', [...]), ('b', [...]), ('c', [...])] |
zip_longest([1,2,3], "ab", fillvalue="-") | [(1,'a'), (2,'b'), (3,'-')] |
pairwise([1,2,3,4]) | [(1,2), (2,3), (3,4)] โ 3.10+ |
groupby only groups adjacent equal keys: it is a run-length encoder, not SQL's GROUP BY. On unsorted input it silently produces one group per run, so groupby("aba") yields three groups rather than two. Sort by the same key first, or use a defaultdict(list), which is \(O(n)\) rather than \(O(n \log n)\) and does not care about order. The second hazard is that each group is a lazy view into the same underlying iterator: advance to the next group and the previous one is empty, so materialise with list(g) before moving on.results = (x * 2 for x in range(5))
print(sum(results)) # -> 20
print(max(results)) # ValueError: max() iterable argument is emptyThe first call exhausted it; the second sees nothing. Symptoms include a second loop that never executes, len() raising TypeError because generators have no length, and a function that quietly returns an empty result the second time it is called. If you need the data more than once, materialise it with list(...) โ and if you cannot afford to, you needed two generators.That closes the first half of this book. Return to the overview for the map of what comes next.
Recap
- Comprehensions filter with a trailing
ifand select with a leading conditional expression; multipleforclauses flatten, nested brackets do not. - Parentheses give a generator expression: lazy, single-pass, \(O(1)\) memory โ 432 bytes against 40.4 MB for a million elements.
- A
defcontainingyieldreturns a paused generator; eachnext()resumes to the nextyield, andreturnends iteration. - The protocol is
__iter__returning an iterator plus__next__raisingStopIteration; an iterator's__iter__returns itself, which is why generators cannot be replayed. itertoolscomposes lazily;isliceis how you truncate an infinite generator, andgroupbyonly groups adjacent runs.- Laziness buys memory and early exit, never fewer instructions on a full pass.
References
- Python Software Foundation. List Comprehensions.
- Python Software Foundation. Functional Programming HOWTO โ iterators and generators.
- Python Software Foundation.
itertoolsโ functions creating iterators for efficient looping. - Python Software Foundation. Glossary: iterator, iterable, generator.
- Schemenauer, N., Peters, T., & Hetland, M. L. PEP 255 โ Simple Generators.
- Hettinger, R. PEP 289 โ Generator Expressions.
- van Rossum, G., & Eby, P. J. PEP 380 โ Syntax for Delegating to a Subgenerator.
