Iterators and Generators
Iteration in Python runs on one small protocol, and generators make implementing it almost free. Together they let you process data that is infinite, or far larger than memory, with ordinary-looking code.
Iterables vs iterators
Section titled “Iterables vs iterators”They are different things, and the distinction explains most iteration surprises.
- An iterable can produce an iterator. It implements
__iter__. Lists, tuples, dicts, sets, strings, and files are all iterable. - An iterator produces values one at a time. It implements
__next__and__iter__(returning itself). It is consumed as you go and cannot be rewound.
xs = [1, 2, 3] # an iterableit = iter(xs) # an iterator over it
next(it) # => 1next(it) # => 2next(it) # => 3next(it) # StopIterationStopIteration is how an iterator says “done”. It is not an error — the for statement catches it internally.
A for loop is exactly this:
for x in xs: body(x)
# desugars toit = iter(xs)while True: try: x = next(it) except StopIteration: break body(x)The critical consequence: an iterator is single-use.
it = iter([1, 2, 3])list(it) # => [1, 2, 3]list(it) # => [] — already exhaustedWhereas an iterable produces a fresh iterator each time:
xs = [1, 2, 3]list(xs) # => [1, 2, 3]list(xs) # => [1, 2, 3]next() accepts a default instead of raising:
next(it, None) # => None when exhaustedWriting an iterator by hand
Section titled “Writing an iterator by hand”class Countdown: def __init__(self, start): self.start = start
def __iter__(self): return CountdownIterator(self.start) # fresh state each time
class CountdownIterator: def __init__(self, current): self.current = current
def __iter__(self): return self
def __next__(self): if self.current <= 0: raise StopIteration self.current -= 1 return self.current + 1
list(Countdown(3)) # => [3, 2, 1]list(Countdown(3)) # => [3, 2, 1] — reusable, because __iter__ makes a new iteratorThat is a lot of code for very little. Generators collapse it to four lines.
Generators
Section titled “Generators”A function containing yield is a generator function. Calling it runs no code — it returns a generator object. Each next() runs the body until the next yield, hands back that value, and freezes the function’s entire state (locals, instruction pointer) until the next call.
def countdown(start): while start > 0: yield start start -= 1
list(countdown(3)) # => [3, 2, 1]g = countdown(3)g # => <generator object countdown at 0x...> — nothing has run yetnext(g) # => 3next(g) # => 2Returning from a generator (or falling off the end) raises StopIteration. A return value inside a generator attaches that value to the exception, which yield from can retrieve.
Generators are iterators, so they are single-use:
g = countdown(3)list(g) # => [3, 2, 1]list(g) # => []Lazy evaluation
Section titled “Lazy evaluation”Nothing is computed until it is requested. That means generators can be infinite.
def naturals(): n = 0 while True: yield n n += 1
from itertools import islicelist(islice(naturals(), 5)) # => [0, 1, 2, 3, 4]def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + bGenerator expressions
Section titled “Generator expressions”Comprehension syntax with parentheses gives a generator instead of a list.
squares = (n * n for n in range(1_000_000)) # allocates nothingsum(squares)Parentheses can be omitted when it is the sole argument to a call:
sum(n * n for n in range(100))any(line.startswith("ERROR") for line in log)max((len(w), w) for w in words)any and all short-circuit, so this stops at the first match rather than scanning everything:
if any(user.is_admin for user in users): # stops at the first admin ...Compare the cost of eager and lazy pipelines over a large file:
# Eager: whole file in memory, twicelines = open("huge.log", encoding="utf-8").readlines()errors = [l for l in lines if "ERROR" in l]count = len(errors)
# Lazy: one line in memory at a timewith open("huge.log", encoding="utf-8") as f: count = sum(1 for line in f if "ERROR" in line)The file object is itself a lazy iterator of lines — never call .readlines() on something large.
Chaining generators into a pipeline
Section titled “Chaining generators into a pipeline”Each stage is lazy, so data flows through one item at a time.
def read_lines(path): with open(path, encoding="utf-8") as f: yield from f
def strip_blank(lines): for line in lines: line = line.strip() if line: yield line
def parse(lines): for line in lines: key, _, value = line.partition("=") yield key.strip(), value.strip()
config = dict(parse(strip_blank(read_lines("app.conf"))))Memory use is constant regardless of file size, and each stage is independently testable.
yield from
Section titled “yield from”yield from iterable delegates to a sub-iterator: it yields everything the sub-iterator produces, and forwards send/throw correctly.
def flatten(nested): for item in nested: if isinstance(item, list): yield from flatten(item) # recursion made trivial else: yield item
list(flatten([1, [2, [3, [4]]], 5])) # => [1, 2, 3, 4, 5]Without it you would write for x in flatten(item): yield x — equivalent for simple cases, but not for the two-way protocol.
yield from also captures the sub-generator’s return value:
def inner(): yield 1 return "done"
def outer(): result = yield from inner() print(result) # => 'done'Sending values in
Section titled “Sending values in”Generators are two-way. gen.send(value) resumes the generator and makes the paused yield expression evaluate to value.
def accumulator(): total = 0 while True: n = yield total total += n
acc = accumulator()next(acc) # prime it — run to the first yield => 0acc.send(10) # => 10acc.send(5) # => 15gen.throw(ExcType) raises inside the generator at the paused yield; gen.close() raises GeneratorExit there, which is how cleanup in a finally gets to run.
When to use what
Section titled “When to use what”| Situation | Use |
|---|---|
| Data fits in memory, needed repeatedly | A list |
| Large or streaming data, one pass | A generator |
| Infinite or unbounded sequence | A generator |
| Simple transform/filter of an iterable | A generator expression |
| Complex per-item logic, multiple yields | A generator function |
| An object that must be iterable many times | A class with __iter__ returning a fresh generator |
That last pattern is the clean way to make a reusable iterable:
class Countdown: def __init__(self, start): self.start = start
def __iter__(self): n = self.start while n > 0: yield n n -= 1Each for loop calls __iter__ and gets a brand-new generator, so it is reusable — with none of the manual state machine.
itertools highlights
Section titled “itertools highlights”itertools is a set of fast, memory-efficient iterator building blocks implemented in C.
from itertools import ( chain, islice, groupby, product, permutations, combinations, count, cycle, repeat, accumulate, takewhile, dropwhile, zip_longest, tee, starmap, compress, pairwise,)chain — concatenate iterables lazily.
list(chain([1, 2], "ab", (3,))) # => [1, 2, 'a', 'b', 3]list(chain.from_iterable([[1, 2], [3]])) # => [1, 2, 3] — flatten one levelislice — slice any iterable, including infinite ones. No negative indices.
list(islice(count(), 5)) # => [0, 1, 2, 3, 4]list(islice(range(20), 5, 15, 2)) # start, stop, stepgroupby — group consecutive items sharing a key.
data = [("a", 1), ("a", 2), ("b", 3)]for key, group in groupby(data, key=lambda t: t[0]): print(key, list(group))# a [('a', 1), ('a', 2)]# b [('b', 3)]product, permutations, combinations — combinatorics without nested loops.
list(product("ab", [1, 2])) # => [('a',1), ('a',2), ('b',1), ('b',2)]list(product([0, 1], repeat=3)) # all 3-bit combinationslist(permutations("abc", 2)) # ordered pairs, 6 of themlist(combinations("abc", 2)) # => [('a','b'), ('a','c'), ('b','c')]Infinite generators.
count(10, 2) # 10, 12, 14, ...cycle("ab") # a, b, a, b, ...repeat(None, 3) # None, None, Noneaccumulate — running totals, or any binary function.
list(accumulate([1, 2, 3, 4])) # => [1, 3, 6, 10]import operatorlist(accumulate([1, 2, 3, 4], operator.mul)) # => [1, 2, 6, 24]list(accumulate([3, 1, 4], max)) # => [3, 3, 4]takewhile / dropwhile — stop or skip based on a predicate.
list(takewhile(lambda n: n < 5, [1, 3, 6, 1])) # => [1, 3] — stops at 6list(dropwhile(lambda n: n < 5, [1, 3, 6, 1])) # => [6, 1]zip_longest, pairwise, tee.
list(zip_longest([1, 2, 3], "ab", fillvalue="?")) # => [(1,'a'), (2,'b'), (3,'?')]list(pairwise([1, 2, 3, 4])) # => [(1,2), (2,3), (3,4)] — 3.10+a, b = tee(iterable, 2) # two independent iteratorsAlso useful: functools.reduce for folds, and heapq.nlargest/nsmallest for top-k without a full sort.
Memory in practice
Section titled “Memory in practice”import sys
sys.getsizeof([n for n in range(1_000_000)]) # ~8 MB of pointerssys.getsizeof((n for n in range(1_000_000))) # ~200 bytesThe generator holds only its frame and current state; the list holds a million references plus the integer objects. For a pipeline that touches each item once, the list is pure waste.
Where a list is still right: you need len(), random access, more than one pass, or to sort. Generators give up all of those.
Key points
Section titled “Key points”- Iterable → has
__iter__; iterator → has__next__and is single-use. forisiter()plusnext()in a loop, catchingStopIteration.- A generator function returns a generator; each
yieldsuspends and resumes the whole frame. - Generator expressions are lazy comprehensions — use them as function arguments.
yield fromdelegates to a sub-iterator and forwards its return value.- Make a reusable iterable by returning a fresh generator from
__iter__. itertoolscovers chaining, slicing, grouping, and combinatorics in C.groupbyneeds sorted input and its groups expire when you advance.