Iterators and generators

Understand what a for loop really does, then write generators that process more data than you have memory for.

17of 24
≈ 14Min read
5Parts

What for actually does

An iterable is anything you can call iter() on: a list, a string, a dict, a file. An iterator is what you get back — something with __next__ that raises StopIteration when it is finished.

The distinction matters because an iterator is consumed. A list can be looped over ten times; the iterator from zip, map or a generator is empty after the first pass, and the second loop silently does nothing.

py
for item in things:
    use(item)

# is exactly this:
it = iter(things)              # things.__iter__()
while True:
    try:
        item = next(it)        # it.__next__()
    except StopIteration:
        break
    use(item)

yield

A function containing yield does not run when you call it. It returns a generator, and the body runs only as values are pulled out — resuming each time exactly where it stopped, with every local variable still in place.

That suspension is the whole feature: it turns a function into something that produces a sequence lazily, with no list in the middle.

py
def countdown(n):
    while n > 0:
        yield n                # hand a value out, and pause right here
        n -= 1
    # falling off the end raises StopIteration

c = countdown(3)
next(c)        # 3
next(c)        # 2
list(c)        # [1] — the two already taken are gone

Why it matters

Generators compose into a pipeline: each stage pulls one item from the one before it, so a chain of five transformations over a ten-gigabyte file still holds one line at a time. This is the single most useful thing in this lesson.

py
# reads the whole file into memory
lines = open("huge.log").readlines()
errors = [l for l in lines if "ERROR" in l]

# constant memory, whatever the file size
def errors(path):
    with open(path, encoding="utf-8") as f:
        for line in f:
            if "ERROR" in line:
                yield line.rstrip("\n")

for line in errors("huge.log"):
    ...

yield from, and returning

yield from hands control to another iterable and passes its values straight through — which makes recursive generators read as naturally as recursive functions.

py
def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)     # delegate to another generator
        else:
            yield item

list(flatten([1, [2, [3, [4]]]]))        # [1, 2, 3, 4]

def counted(items):
    n = 0
    for item in items:
        n += 1
        yield item
    return n                              # ends up on StopIteration.value

itertools

groupby groups consecutive equal keys, not all equal keys; unsorted input gives you several groups with the same key and a bug that only shows up on real data. Sort by the same key first, or use a defaultdict.

py
from itertools import islice, chain, groupby, count, cycle, accumulate, pairwise

islice(gen, 10)                    # the first ten, without building a list
chain(a, b, c)                     # one sequence after another
accumulate([1, 2, 3])              # 1, 3, 6 — running totals
pairwise([1, 2, 3])                # (1, 2), (2, 3)
count(1)                           # 1, 2, 3, … forever
groupby(sorted(rows, key=k), key=k)  # runs of equal keys — SORT FIRST

Practice