Comprehensions and Generators: Building Sequences Without Building Them

7 minute read

Published:

TL;DR: A comprehension is a compact 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
ExpressionAllocatedWhy
[x * 2 for x in range(1_000_000)]40.4 MB8.4 MB of pointers, plus a million separate int objects
(x * 2 for x in range(1_000_000))432 bytesone 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.

FunctionResult
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+
Key Insight โ€” 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.
The classic trap โ€” a generator is consumed exactly once. The bug looks like this:
results = (x * 2 for x in range(5))
print(sum(results))    # -> 20
print(max(results))    # ValueError: max() iterable argument is empty
The 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 if and select with a leading conditional expression; multiple for clauses 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 def containing yield returns a paused generator; each next() resumes to the next yield, and return ends iteration.
  • The protocol is __iter__ returning an iterator plus __next__ raising StopIteration; an iterator's __iter__ returns itself, which is why generators cannot be replayed.
  • itertools composes lazily; islice is how you truncate an infinite generator, and groupby only groups adjacent runs.
  • Laziness buys memory and early exit, never fewer instructions on a full pass.

References

  1. Python Software Foundation. List Comprehensions.
  2. Python Software Foundation. Functional Programming HOWTO โ€” iterators and generators.
  3. Python Software Foundation. itertools โ€” functions creating iterators for efficient looping.
  4. Python Software Foundation. Glossary: iterator, iterable, generator.
  5. Schemenauer, N., Peters, T., & Hetland, M. L. PEP 255 โ€” Simple Generators.
  6. Hettinger, R. PEP 289 โ€” Generator Expressions.
  7. van Rossum, G., & Eby, P. J. PEP 380 โ€” Syntax for Delegating to a Subgenerator.