Step 06 of 13
Iterators & generators
Understand the iteration protocol, write generators with yield, build lazy pipelines that process huge data in constant memory, and use itertools.
Every for loop in Python runs on one small protocol, and once you understand it you can make anything loopable, process files far larger than your memory, and chain transformations together without building intermediate lists. This lesson explains iterables and iterators, generator functions with yield, lazy pipelines, and the itertools module that provides the standard building blocks.
Iterables and iterators
An iterable is anything you can loop over: lists, strings, dicts, files, ranges. An iterator is the object that actually produces the values, one at a time, and remembers where it is. for gets an iterator from the iterable with iter(), then calls next() until the iterator signals it's done:
colours = ["red", "green"]
it = iter(colours)
print(next(it)) # red
print(next(it)) # green
print(next(it)) # raises StopIterationA for loop is exactly this, with the StopIteration handled for you:
it = iter(colours)
while True:
try:
colour = next(it)
except StopIteration:
break
print(colour)Two consequences matter in practice:
- A list can be iterated many times, because each
forloop asks it for a fresh iterator. - An iterator is consumed as you use it. Loop over it a second time and you get nothing:
squares = (n * n for n in range(3)) # a generator expression is an iterator
print(list(squares)) # [0, 1, 4]
print(list(squares)) # [] already exhaustedThat second empty list is a classic source of confusing bugs. If you need the values twice, store them in a list.
Generator functions
Writing an iterator class by hand (with __iter__ and __next__) works but is verbose. A generator function does the same with yield:
def countdown(start: int):
print("starting")
n = start
while n > 0:
yield n
n -= 1
print("done")
for x in countdown(3):
print(x)starting
3
2
1
doneCalling countdown(3) doesn't run the body. It returns a generator object. Each time the loop asks for the next value, the function runs until it hits yield, hands over that value, and pauses with all its local variables intact. The next request resumes right after the yield. When the function returns, iteration stops.
That pausing is what makes generators powerful: they produce values on demand, one at a time, so they never need to hold all of them in memory.
def fibonacci():
a, b = 0, 1
while True: # an infinite sequence is fine: values are produced only when asked for
yield a
a, b = b, a + b
from itertools import islice
print(list(islice(fibonacci(), 10))) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]Type hint generators with Iterator[T] (or Generator[T, None, None] when you need the extra details) from collections.abc.
Lazy pipelines: processing big data in constant memory
Suppose you have a 20 GB web server log and want the ten most requested paths that returned errors. Reading it into a list would need 20 GB of RAM. A chain of generators processes one line at a time:
from collections import Counter
from collections.abc import Iterable, Iterator
def read_lines(path: str) -> Iterator[str]:
with open(path, encoding="utf-8") as f:
for line in f: # files are already lazy iterators over lines
yield line.rstrip("\n")
def parse(lines: Iterable[str]) -> Iterator[dict[str, str]]:
for line in lines:
parts = line.split()
if len(parts) < 9:
continue # skip malformed lines
yield {"path": parts[6], "status": parts[8]}
def errors_only(records: Iterable[dict[str, str]]) -> Iterator[dict[str, str]]:
return (r for r in records if r["status"].startswith("5"))
records = errors_only(parse(read_lines("access.log")))
top = Counter(r["path"] for r in records).most_common(10)Nothing happens until Counter starts pulling values. Then each line flows through the whole pipeline (read, parse, filter, count) before the next line is read. Memory use stays constant whether the file is 2 KB or 20 GB, and each stage is a small function you can test on its own with a list of sample lines.
yield from delegates to another iterable, which keeps flattening and recursive generators short:
def read_many(paths: list[str]) -> Iterator[str]:
for path in paths:
yield from read_lines(path) # same as: for line in read_lines(path): yield lineThe itertools toolbox
The itertools module has fast, lazy building blocks for common iteration patterns. The ones worth knowing:
from itertools import batched, chain, count, groupby, islice, pairwise, product
list(islice(count(start=1), 5)) # [1, 2, 3, 4, 5] count() is infinite
list(chain([1, 2], (3, 4), "ab")) # [1, 2, 3, 4, 'a', 'b']
list(pairwise([1, 4, 9, 16])) # [(1, 4), (4, 9), (9, 16)] (3.10+)
list(batched(range(7), 3)) # [(0, 1, 2), (3, 4, 5), (6,)] (3.12+)
list(product("ab", [1, 2])) # [('a', 1), ('a', 2), ('b', 1), ('b', 2)]pairwise is great for differences between neighbours ([b - a for a, b in pairwise(readings)]). batched sends work in chunks, such as inserting rows 1,000 at a time.
groupby groups consecutive items with the same key, so sort first unless the data is already ordered:
from itertools import groupby
orders = [("ana", 30), ("ben", 12), ("ana", 5), ("ben", 8), ("cy", 40)]
orders.sort(key=lambda o: o[0])
for customer, group in groupby(orders, key=lambda o: o[0]):
print(customer, sum(amount for _, amount in group))ana 35
ben 20
cy 40Forgetting to sort before groupby is the most common mistake with it: unsorted input produces several separate groups for the same key.
When to be lazy and when not to
Lazy iteration isn't always better:
| Use a generator or iterator when | Use a list when |
|---|---|
| The data is large or unbounded (files, streams, infinite sequences) | The data is small and you need it several times |
You might stop early (break, any, the first match) | You need len(), indexing or slicing |
| Each stage is a step in a pipeline | You'll sort it or search it repeatedly |
| Producing each value is expensive and not all are needed | You want to inspect it while debugging |
Many standard functions accept any iterable and return iterators already: map, filter, zip, enumerate, reversed, dict.items(). Wrap in list(...) when you need a list.
Practice
You have a large CSV of sensor readings, one per line, in time order: timestamp,sensor_id,value. Write a pipeline of generators that:
- Reads the file lazily and skips the header and blank lines.
- Parses each line into a tuple
(timestamp: str, sensor: str, value: float), skipping lines whose value isn't a number. - Keeps only readings for a given sensor.
- Yields the change from the previous reading of that sensor (
value - previous_value), together with the timestamp. - Finally, prints the first 5 changes larger than 10 in absolute value, without processing the rest of the file.
Test it on a small file you create, then generate a file with a million lines and confirm memory stays flat.
Show solution
# readings.py
import random
from collections.abc import Iterable, Iterator
from itertools import islice, pairwise
def read_rows(path: str) -> Iterator[str]:
with open(path, encoding="utf-8") as f:
next(f, None) # skip the header
for line in f:
if line.strip():
yield line.rstrip("\n")
def parse(lines: Iterable[str]) -> Iterator[tuple[str, str, float]]:
for line in lines:
timestamp, sensor, raw = line.split(",")
try:
yield timestamp, sensor, float(raw)
except ValueError:
continue
def for_sensor(readings: Iterable[tuple[str, str, float]], sensor_id: str) -> Iterator[tuple[str, float]]:
return ((ts, value) for ts, sensor, value in readings if sensor == sensor_id)
def changes(readings: Iterable[tuple[str, float]]) -> Iterator[tuple[str, float]]:
for (_, prev), (ts, curr) in pairwise(readings):
yield ts, curr - prev
def make_sample(path: str, n: int) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write("timestamp,sensor_id,value\n")
value = 20.0
for i in range(n):
value += random.uniform(-6, 6)
f.write(f"t{i},{random.choice(['a', 'b'])},{value:.2f}\n")
if __name__ == "__main__":
make_sample("readings.csv", 1_000_000)
pipeline = changes(for_sensor(parse(read_rows("readings.csv")), "a"))
big = (c for c in pipeline if abs(c[1]) > 10)
for ts, delta in islice(big, 5):
print(f"{ts}: {delta:+.2f}")islice(big, 5) pulls only until five large changes are found, so the program typically reads a few hundred lines of the million and stops. Each stage holds at most one or two readings in memory, so a 10-million-line file uses the same memory as a 10-line one. pairwise turns "compare with the previous item" into a one-liner without managing a previous variable by hand.
Key takeaways
forcallsiter()on an iterable andnext()on the iterator untilStopIteration; iterators are consumed as you use them.- A generator function with
yieldpauses and resumes, producing values on demand. - Chains of generators form lazy pipelines that process arbitrarily large data in constant memory, one item at a time.
itertoolsprovidesislice,chain,pairwise,batched,groupby(sort first) and more.- Use lazy iteration for large, unbounded or early-exit work; use lists when you need the data more than once, its length or random access.
Check yourself
Quiz
0/4 correct · 4 left
1.gen = (x * 2 for x in range(3)); print(sum(gen)); print(sum(gen)). What is printed?
2.What happens when you call a generator function such as countdown(3)?
3.Why can a pipeline of generators process a 20 GB log file on a laptop with 8 GB of RAM?
4.groupby returns two separate groups for the same customer. What's the likely cause?
Finished reading?
Mark it complete to fill in your roadmap.