Part 2 · Intermediate Python · Module 14
Iterators & Generators
The for loop is the most-used statement in Python, and behind it sits one tiny protocol. Once you understand it, you can process a 50 GB log file in constant memory, describe infinite sequences, and chain data transformations into clean, lazy pipelines.
- Distinguish iterables from iterators and use
iter()andnext()directly - Explain exactly what a
forloop does, step by step - Write iterator classes and, more often, generator functions with
yield - Use generator expressions and measure their memory advantage
- Delegate with
yield fromand understandsend()andclose() - Build lazy pipelines over large files and infinite streams
- Apply the essential
itertoolstools, plusbatched(strict=True)andmap(strict=True)
Iterables and iterators
Two words that sound alike mean different things, and keeping them apart is the key to this module.
- An iterable is anything you can loop over: lists, strings, dicts, sets, files,
rangeobjects. Technically, an object is iterable ifiter(obj)works, which means its type defines__iter__(or the older__getitem__fallback from M11). - An iterator is the object that does the actual stepping. It remembers a position and hands out one item each time you call
next()on it, raisingStopIterationwhen it's exhausted. Its type defines__next__, and its__iter__returns itself.
An analogy: a book is an iterable; a bookmark is an iterator. Many readers can each have their own bookmark in the same book, and a bookmark only moves forward.
The exhaustion trap
A list can be looped over any number of times, because each loop calls iter() and gets a fresh iterator. An iterator, once consumed, stays empty. Many built-ins return iterators (map, filter, zip, enumerate, reversed, file objects, generators), so this surprises everyone at least once:
Remember: an iterable can produce iterators; an iterator produces items, once, until StopIteration.
The for loop, desugared
"Syntactic sugar" is syntax that is shorthand for something longer. The for loop is sugar for a while loop over an iterator. These two programs do exactly the same thing:
for item in iterable:
body(item)
_it = iter(iterable)
while True:
try:
item = next(_it)
except StopIteration:
break
body(item)
Step through the desugared version. Watch the iterator's hidden position advance and the loop end when StopIteration is caught:
This is the whole iterator protocol: __iter__ to get an iterator, __next__ to get items, StopIteration to finish. Every consumer of iterables, including list(), sum(), sorted(), in, unpacking (a, b = pair), *args, comprehensions, and str.join, speaks this same protocol. So any object you make iterable works with all of them.
Remember: for calls iter() once, then next() repeatedly until StopIteration.
Writing an iterator class
To make your own iterator, implement the protocol directly: __next__ returns the next item or raises StopIteration, and __iter__ returns self.
A container that should be loopable many times must not be its own iterator. Instead, its __iter__ returns a new iterator each time. This is the iterable/iterator split from the first section, in code:
That is a lot of code for "go through a list". Generators, next, collapse it to three lines.
Remember: iterators implement __next__ and return self from __iter__; reusable containers return a fresh iterator from __iter__.
Generator functions and yield
A generator function is any function whose body contains yield. Calling it does not run the body. Instead it returns a generator: an iterator that runs the body lazily. Each next() runs the code until the next yield, hands out the yielded value, and then pauses, freezing the function's local variables and position. The next next() resumes right after the yield. When the function returns, the generator raises StopIteration.
Step through the trace and watch the generator's frame appear, pause with its locals intact, and resume, interleaved with the caller's code:
Notice that calling countdown(3) executed no line of the body; the first line ran only on next(gen). That's lazy evaluation: work is done only when a value is requested. The return value isn't yielded; it rides along inside the StopIteration exception (used by yield from, below).
Now the Playlist from the previous section, with a generator as its __iter__. Each call creates a new generator, so it's automatically reusable:
Remember: calling a generator function returns a paused generator; each next() runs to the next yield; locals survive between steps.
Generator expressions and memory
A generator expression is a comprehension in parentheses: (n * n for n in nums). It produces a generator instead of a list. When it's the only argument to a function, the extra parentheses can be dropped: sum(n * n for n in nums).
The difference is memory. A list comprehension builds every element at once; a generator expression stores only its current state, regardless of how many items it will produce.
sys.getsizeof reports only the container itself; the million int objects the list points to take tens of megabytes more. The generator's size is the same for ten items or ten billion.
List comprehension [...] | Generator expression (...) | |
|---|---|---|
| Evaluation | Eager: all items now | Lazy: one item per next() |
| Memory | Proportional to length | Constant |
| Reusable | Yes, loop as often as you like | No, single pass |
Indexing, len() | Yes | No |
| Best for | Results you'll reuse, index, or that are small | Feeding sum, max, any, join, and other one-pass consumers |
any stops at the first true value, and because the generator is lazy, check(5) and check(7) never run. With a list comprehension inside any([...]), all four checks would run first.
Remember: parentheses make it lazy and constant-memory; use generator expressions for single-pass consumers.
yield from, send, and close
Delegating with yield from
yield from iterable yields every item of another iterable, as if you'd written for x in iterable: yield x. With a sub-generator it does more: it passes send() and close() through, and its value is the sub-generator's return value. It's the natural tool for recursive generators, such as walking a tree:
send() and close() (briefly)
A yield is also an expression: received = yield value. Calling gen.send(x) resumes the generator and makes that yield evaluate to x. (next(gen) is the same as gen.send(None).) You must start a generator with next() before you can send to it. gen.close() raises GeneratorExit at the paused yield so finally blocks run and resources are released.
Remember: yield from delegates to another iterable (and returns a sub-generator's result); send passes values in; close triggers cleanup.
Infinite generators and lazy pipelines
Because generators produce values on demand, they can describe sequences with no end. The consumer decides when to stop, usually with itertools.islice, a break, or a function like next or any.
Pipelines over big files
The real professional payoff is processing data that doesn't fit in memory. A file object is already a lazy iterator over lines. Chain generator stages, each taking an iterator and yielding transformed items, and the whole pipeline holds just one line at a time, whether the file has 100 lines or 100 million.
def read_lines(path):
with open(path, encoding="utf-8") as f: # the file closes when the generator finishes
for line in f: # one line in memory at a time
yield line.rstrip("\n")
def parse(lines):
for line in lines:
level, _, message = line.partition(" ")
yield {"level": level, "message": message}
def errors_only(records):
return (r for r in records if r["level"] == "ERROR")
# Nothing is read until the final consumer pulls values through:
pipeline = errors_only(parse(read_lines("app.log")))
print(sum(1 for _ in pipeline))
The visualizer below shows the same idea in a data pipeline. Switch between batch mode (each stage processes everything before the next starts, like chaining lists) and streaming mode (records flow through one at a time, like chaining generators). Notice how much sooner the first record arrives when streaming.
Try a runnable version, with an in-memory "file" (an io.StringIO behaves like an open text file) and a print in each stage so you can see the interleaving:
Remember: infinite generators are fine because consumers stop them; chain generator stages for constant-memory processing of any amount of data.
The itertools toolkit
The standard library's itertools module is a set of fast, memory-efficient building blocks for iterators, implemented in C. Learning them saves you from writing (and debugging) your own loops.
| Tool | What it does | Example → result |
|---|---|---|
count(start, step) | Infinite arithmetic sequence | count(10, 5) → 10, 15, 20, … |
cycle(it) | Repeat an iterable forever | cycle("ab") → a, b, a, b, … |
repeat(x, n) | The same value, n times (or forever) | repeat(0, 3) → 0, 0, 0 |
islice(it, start, stop, step) | Slice any iterator lazily | islice(count(), 2, 8, 2) → 2, 4, 6 |
chain(a, b, …) | Concatenate iterables | chain([1], [2, 3]) → 1, 2, 3 |
chain.from_iterable(its) | Flatten one level | [[1], [2]] → 1, 2 |
groupby(it, key) | Group consecutive items with equal keys | "aabba" → (a, aa), (b, bb), (a, a) |
accumulate(it, func) | Running totals (or running max, etc.) | [1, 2, 3, 4] → 1, 3, 6, 10 |
pairwise(it) | Overlapping pairs (3.10+) | [1, 2, 4] → (1, 2), (2, 4) |
batched(it, n) | Non-overlapping tuples of size n (3.12+) | "ABCDE", 2 → AB, CD, E |
product(a, b) | Cartesian product (nested loops) | product("ab", repeat=2) → aa, ab, ba, bb |
permutations(it, r) | Ordered arrangements | permutations("abc", 2) → ab, ac, ba, … |
combinations(it, r) | Unordered selections | combinations("abc", 2) → ab, ac, bc |
Remember: reach for itertools before writing a loop; groupby needs sorted input.
Strictness: zip, batched, and map
Lazy tools have one dangerous default: when input lengths don't line up, they quietly stop or produce a short final chunk. zip(names, scores) silently drops extra names if scores is shorter, hiding a data bug. Modern Python adds strict=True options that turn the mismatch into a ValueError:
zip(a, b, strict=True)(3.10+): raise if the iterables have different lengths.itertools.batched(it, n, strict=True)(3.13+): raise if the final batch is incomplete.map(func, a, b, strict=True)(3.14+): raise if the iterables have different lengths.
Remember: pass strict=True to zip, batched (3.13+), and map (3.14+) when lengths must match.
Practice
Write an iterator class Steps(start, stop, step) that behaves like range for positive steps but also works with floats: it yields start, start + step, … while the value is less than stop. Compute each value as start + i * step (not by repeated addition) to avoid accumulating floating-point error. Raise ValueError in __init__ if step <= 0.
Hint
Keep a counter self._i. In __next__, compute value = self.start + self._i * self.step; if value >= self.stop, raise StopIteration.
Show solution
class Steps:
def __init__(self, start, stop, step):
if step <= 0:
raise ValueError("step must be positive")
self.start = start
self.stop = stop
self.step = step
self._i = 0
def __iter__(self):
return self
def __next__(self):
value = self.start + self._i * self.step
if value >= self.stop:
raise StopIteration
self._i += 1
return value
Write an infinite generator collatz(n) that yields n, then the next Collatz value (n // 2 if even, else 3 * n + 1), forever. Then write take(n, iterable) returning a list of the first n items (use itertools.islice), and collatz_length(n) returning how many values the sequence has up to and including the first 1.
Hint
For collatz_length, loop over enumerate(collatz(n), start=1) and return the count when the value is 1.
Show solution
from itertools import islice
def collatz(n):
while True:
yield n
n = n // 2 if n % 2 == 0 else 3 * n + 1
def take(n, iterable):
return list(islice(iterable, n))
def collatz_length(n):
for count, value in enumerate(collatz(n), start=1):
if value == 1:
return count
Write a generator windows(iterable, size) that yields tuples of size consecutive items: windows([1, 2, 3, 4], 3) yields (1, 2, 3) and (2, 3, 4). It must work on any iterable, including infinite generators, using constant memory (use collections.deque(maxlen=size)). Then write moving_average(values, size) as a generator of window averages.
Hint
Append each item to a deque(maxlen=size); once len(window) == size, yield tuple(window).
Show solution
from collections import deque
def windows(iterable, size):
window = deque(maxlen=size)
for item in iterable:
window.append(item)
if len(window) == size:
yield tuple(window)
def moving_average(values, size):
for w in windows(values, size):
yield sum(w) / size
Write a generator flatten(obj) that yields every leaf of arbitrarily nested lists, tuples, and sets, using yield from for the recursion. Strings and bytes must be treated as leaves (not split into characters), as must dicts.
Hint
If obj is a list, tuple, or set, loop over it and yield from flatten(item); otherwise yield obj.
Show solution
def flatten(obj):
if isinstance(obj, (list, tuple, set)):
for item in obj:
yield from flatten(item)
else:
yield obj
Build three generator stages and a consumer. Lines look like "2026-10-01T12:00:00 ERROR db timeout" (timestamp, level, message, separated by single spaces; the message may contain spaces).
parse(lines): skip blank lines and lines starting with#; yield tuples(timestamp, level, message).at_least(records, level): yield records whose level is at or abovelevelin the orderDEBUG < INFO < WARN < ERROR.messages(records): yield just the messages.count_levels(records): return a dict of level counts (this one consumes).
The stages must be lazy: they must not read more input than needed.
Hint
line.split(" ", 2) splits into at most three parts. Compare levels with LEVELS.index(r[1]) >= LEVELS.index(level).
Show solution
LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"]
def parse(lines):
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
timestamp, level, message = line.split(" ", 2)
yield timestamp, level, message
def at_least(records, level):
threshold = LEVELS.index(level)
for record in records:
if LEVELS.index(record[1]) >= threshold:
yield record
def messages(records):
for _, _, message in records:
yield message
def count_levels(records):
counts = {}
for _, level, _ in records:
counts[level] = counts.get(level, 0) + 1
return counts
Using itertools, write rle_encode(text) returning a list of (char, count) pairs for consecutive runs ("aaabcc" → [("a", 3), ("b", 1), ("c", 2)]), and rle_decode(pairs) returning the original string. Use groupby for encoding and chain.from_iterable with repeat for decoding.
Hint
Encode: [(ch, len(list(group))) for ch, group in groupby(text)]. Decode: "".join(chain.from_iterable(repeat(ch, n) for ch, n in pairs)).
Show solution
from itertools import groupby, chain, repeat
def rle_encode(text):
return [(ch, sum(1 for _ in group)) for ch, group in groupby(text)]
def rle_decode(pairs):
return "".join(chain.from_iterable(repeat(ch, n) for ch, n in pairs))
Mini-project: a log-processing pipeline
Quiz
Recap
- An iterable gives iterators (
__iter__); an iterator gives items (__next__) and raisesStopIteration. for x in it=iter()once, thennext()untilStopIteration.- Iterators are single-use; containers should return a fresh iterator from
__iter__. - Generator functions (
yield) pause and resume, keeping their locals; calling one runs nothing yet. - Generator expressions are lazy and constant-memory, ideal for one-pass consumers.
yield fromdelegates;sendpasses values in;closeruns cleanup.- Infinite generators are safe; consumers decide when to stop. Chain stages for big-data pipelines.
itertoolsprovides count, cycle, islice, chain, groupby (sorted input!), accumulate, pairwise, batched, product, permutations, combinations.- Use
strict=Truewithzip,batched(3.13+), andmap(3.14+).
Going further
- Iterator types and the glossary on generators
- Functional Programming HOWTO: iterators and generators
itertools, including the "Itertools Recipes" section- PEP 255 (simple generators), PEP 380 (
yield from) - David Beazley: Generator Tricks for Systems Programmers
- Next: M15 Decorators & Context Managers