Iterators and generators
The iteration protocol, yield, lazy pipelines and itertools, in small memory.
Create a free account Try part of it here
Why it matters
A for loop over a list is fine for a thousand items. A log file with ten million lines, a stream of API pages, or batches of training data for a model won't fit in memory as one list. Generators produce values one at a time, only when asked, so a program can process data far bigger than memory with a few lines of ordinary-looking code. PyTorch's data loaders, file reading and many APIs work this way.
What you'll be able to do
- Explain the iteration protocol: iter(), next() and StopIteration
- Write generator functions with yield and yield from, and generator expressions
- Chain generators into pipelines that process big inputs in small, constant memory
- Reach for itertools for common patterns: islice, chain, groupby, accumulate
What it covers
- What a for loop really does
- Generators: functions that pause
- Generator expressions, and what memory costs
- Pipelines
- Batches
- itertools: the standard pipeline parts
- Where laziness pays, and where it doesn't
- Worked example: daily totals from a big log, in small memory
What a for loop really does
A for loop works on anything iterable. It calls iter() to get an iterator, then calls next() on it until the iterator raises StopIteration:
skus = ["TE-101", "PA-108", "FO-120"] it = iter(skus) print(next(it)) print(next(it)) print(next(it)) try: next(it) except StopIteration: print("done: that's how a for loop knows to stop") with open("stock.txt", "w") as f: f.write("TE-101 4\nPA-108 0\nFO-120 12\n") with open("stock.txt") as f: print(next(f).strip()) for line in f: print("rest:", line.strip())
A list is iterable but isn't an iterator itself: every iter(skus) call makes a fresh iterator that starts at the beginning, which is why you can loop over a list as often as you like. An iterator remembers where it is, and it's used up once it's done. A file object is its own iterator: after next(f) reads the first line, the for loop carries on from the second. That's also why a file can be read line by line without loading the whole thing.
Generators: functions that pause
A function containing yield is a generator function. Calling it doesn't run the body; it returns a generator. Each next() runs the body until the next yield, hands out that value, and pauses right there, with all its local variables kept, until the next request:
def countdown(n): print("starting") while n > 0: yield n n -= 1 print("finished") gen = countdown(3) print("created, nothing ran yet") print(next(gen)) print(list(gen)) def pages(total, size): for start in range(0, total, size): yield range(start, min(start + size, total)) for page in pages(7, 3): print(list(page))
"starting" only prints when the first value is asked for. list(gen) asks for everything that's left, so it runs to the end and prints "finished". The pages generator is the shape of every paginated API client: it yields one page at a time, so the caller never has to deal with the paging.
Step through: the generator's body only runs when next() or list() asks for a value, and each yield pauses it with n still remembered.
def evens(limit): for n in range(limit): if n % 2 == 0: yield n g = evens(7) print(next(g), next(g), sum(g))
Show the answer
0 2 10
The evens below 7 are 0, 2, 4, 6. Two next() calls take 0 and 2; sum() consumes the rest: 4 + 6 = 10.
Generator expressions, and what memory costs
A generator expression looks like a list comprehension with round brackets. It produces items lazily instead of building a list. tracemalloc measures how much memory Python allocates, so the difference can be seen:
import tracemalloc tracemalloc.start() squares = [n * n for n in range(1_000_000)] total = sum(squares) _, peak_list = tracemalloc.get_traced_memory() del squares tracemalloc.reset_peak() total2 = sum(n * n for n in range(1_000_000)) _, peak_gen = tracemalloc.get_traced_memory() tracemalloc.stop() print(total == total2) print(f"list: {peak_list / 1e6:.1f} MB, generator: {peak_gen / 1e6:.3f} MB")
The list holds a million numbers at once; the generator holds one at a time. When a function like sum, max, any or "".join is the only argument's consumer, you can even drop the extra brackets: sum(n * n for n in range(10)).
The trade-off: a generator can be read only once, and has no len() or [i]. If you need the items several times, or need to index them, make a list.
Pipelines
Generators chain together. Each stage takes an iterable and yields transformed items, so data flows through the whole pipeline one item at a time:
def read_lines(lines): for line in lines: yield line.rstrip("\n") def parse(lines): for line in lines: if line and not line.startswith("#"): date, sku, qty = line.split(",") yield {"date": date, "sku": sku, "qty": int(qty)} def only(prefix, rows): yield from (r for r in rows if r["sku"].startswith(prefix)) raw = ["# date,sku,qty\n", "2025-06-01,TE-101,1\n", "2025-06-01,PA-108,2\n", "\n", "2025-06-02,TE-104,1\n"] tents = only("TE", parse(read_lines(raw))) for row in tents: print(row)
yield from hands out every item of another iterable, one by one. Swap raw for an open file and the same pipeline handles a 20 GB log in a few kilobytes of memory. Each stage stays small and testable on a short list, which is how data-loading code for machine learning is usually built.
Batches
Write a generator batches(items, size) that yields lists of up to size items, the way training code feeds a model in mini-batches. It must work on any iterable, including another generator, without making a full list first.
def batches(items, size): pass print(list(batches(range(7), 3)))
itertools: the standard pipeline parts
The itertools module has the stages you'd otherwise write by hand:
from itertools import accumulate, chain, groupby, islice, count print(list(islice(count(1000), 3))) print(list(chain(["TE-101"], ("PA-108", "FO-120")))) print(list(accumulate([120, 45, 300]))) rows = [("2025-06-01", 3), ("2025-06-01", 1), ("2025-06-02", 5), ("2025-06-02", 2), ("2025-06-03", 4)] for day, group in groupby(rows, key=lambda r: r[0]): print(day, sum(qty for _, qty in group))
islicetakes a slice of any iterator, even an endless one likecount().chainjoins iterables end to end.accumulategives running totals.groupbygroups neighboring items with the same key. Unlike pandas' groupby, it only works on input that's already sorted by that key; an unsorted day would start a new group. Logs written in time order are sorted already, which is whygroupbysuits them.
Where laziness pays, and where it doesn't
Lazy iteration is everywhere once you look:
- Files are read a line at a time with
for line in f. Never callf.readlines()on a file that might be big. - APIs with pages: a client generator yields items across pages, so callers write one simple loop and only the pages they actually reach get fetched.
- Databases: cursors hand back rows in batches, and ORMs can stream results instead of loading a whole table.
- Machine learning: training code loops over a data loader that yields one mini-batch at a time, because a dataset of images or text rarely fits in memory.
- Built-ins:
range,zip,map,enumerateanddict.items()are all lazy already.
Laziness has costs too. A generator can be used only once, so if two parts of a program need the same data, give them a list or create the generator twice. Errors appear late, when the item that triggers them is reached, not when the generator is created. And a pipeline is harder to inspect in the middle: when debugging, list(islice(stage, 5)) shows the first five items any stage produces. Use generators when the data is big, endless or expensive to fetch, and plain lists when it's small and used more than once. Most real programs mix both: a lazy pipeline that reads and filters, ending in a small list of results.
Worked example: daily totals from a big log, in small memory
Build a 200,000-line order log as a generator, total it per day through a pipeline, and measure peak memory.
import random import tracemalloc from itertools import groupby def order_log(days, per_day, seed=1): rng = random.Random(seed) for d in range(days): date = f"2025-01-{d % 28 + 1:02d}" if d < 28 else f"2025-02-{d - 27:02d}" for _ in range(per_day): yield f"{date},{rng.choice(['TE-101', 'PA-108', 'FO-120'])},{rng.randint(1, 3)},{rng.choice([20, 55, 200])}" def parse(lines): for line in lines: date, sku, qty, price = line.split(",") yield date, int(qty) * int(price) def daily_totals(rows): for date, group in groupby(rows, key=lambda r: r[0]): yield date, sum(amount for _, amount in group) tracemalloc.start() totals = list(daily_totals(parse(order_log(days=40, per_day=5000)))) _, peak = tracemalloc.get_traced_memory() tracemalloc.stop() print(len(totals), "days; first:", totals[0], "last:", totals[-1]) print(f"peak memory: {peak / 1e6:.2f} MB for 200,000 lines")
order_log makes lines on demand, the way reading a file would. parse and daily_totals each handle one item at a time, and groupby works because the log is in date order. Only the 40 daily totals are ever kept: the peak memory stays far below what 200,000 lines held in a list would need. To run the same pipeline on a real file, replace order_log(...) with open("orders.csv").
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
When does the body of a generator function start running?