Timing close_till() in the previous lesson left a question hanging: to sum today's sales, the function reads the whole of sales.csv and turns it into a list. With this week's four sales it makes no difference; with five years of Papyrus history, that list occupies memory to compute... a single number. Generators are Python's answer to this waste: they produce values one at a time, on demand, never materializing the whole collection. This lesson takes apart yield and the iteration protocol under every for you've written since module 2, and builds a sales-processing pipeline with it.
Contents
- The problem: loading everything vs processing on demand
yield: functions that pause- The iteration protocol:
next()andStopIteration - Generators vs lists: the truth table
- Generator expressions
- Big files line by line: the natural generator
- Chaining generators: the sales pipeline
yield from, brieflyitertools:isliceandchain- When a list and when a generator
The problem: loading everything vs on demand
Here's how a naive version of the close could sum the day (with the csv and minimal try of M6/M7):
import csv
def amounts_for_day(path, date):
rows = list(csv.DictReader(path.open(encoding="utf-8"))) # EVERYTHING in memory!
amounts = []
for row in rows:
if row["date"] == date:
amounts.append(float(row["amount"]))
return amountsTwo complete lists (rows and amounts) just to end up calling sum(). If sales.csv has a million rows, there are a million dictionaries alive at once. The alternative: produce each amount exactly when it's needed and forget it right after. Constant memory, no matter the size of the file.
yield: functions that pause
A generator is written like a normal function, but with yield where a normal function would put return... with a radical difference in behavior:
def first_sales():
print("preparing the first")
yield ("2026-07-13", "Hamlet", 20.70)
print("preparing the second")
yield ("2026-07-13", "The Odyssey", 13.00)
print("no more")
g = first_sales()
print(type(g)) # <class 'generator'> — NOTHING has been printed yet!Calling first_sales() does not run its body: it returns a generator object, paused at the first line. The body advances only when someone asks for a value:
print(next(g))
# preparing the first
# ('2026-07-13', 'Hamlet', 20.7)
print(next(g))
# preparing the second
# ('2026-07-13', 'The Odyssey', 13.0)
next(g)
# no more
# Traceback ... StopIterationEach next() resumes the body exactly where it left off — with its local variables intact, like a bookmark in one of the shop's books — runs until the next yield, hands over the value and pauses again. When the body finishes, StopIteration is raised.
sequenceDiagram
participant C as Consumer (for / next)
participant G as Generator
C->>G: next(g)
activate G
Note over G: runs until the 1st yield
G-->>C: hands over a value and PAUSES
deactivate G
C->>G: next(g)
activate G
Note over G: resumes after the yield,<br/>locals intact
G-->>C: next value, pauses
deactivate G
C->>G: next(g)
activate G
Note over G: the body finishes
G-->>C: StopIteration
deactivate G
The iteration protocol
This isn't a separate mechanism: it's what for has been doing under the hood since module 2. A loop for x in something: calls iter(something), then next() repeatedly, and catches StopIteration to finish cleanly. That's why you can traverse a generator with for without ever seeing the exception:
Lists, dictionaries, files, range: they all speak this same protocol. Generators are simply the most convenient way to create objects that speak it.
Generators vs lists
def squares_list(n):
return [i * i for i in range(n)] # materializes n values
def squares_gen(n):
for i in range(n):
yield i * i # produces one at a time| Aspect | List | Generator |
|---|---|---|
| Memory | All elements at once | One element at a time (constant) |
| When is it computed? | On creation (eager) | When each value is requested (lazy) |
| Traversals | As many as you want | Just one — then it's exhausted |
len(), indexing [i], repeated in |
Yes | No |
| Infinite sequences possible | No (finite memory) | Yes (produces as long as it's asked) |
Debugging with print() |
Easy: you see the contents | You only see <generator object ...> |
The most treacherous row is the single traversal:
g = squares_gen(4)
print(sum(g)) # 14
print(sum(g)) # 0 ← exhausted! No error raised: it just no longer produces anythingA generator is like the till's paper roll: it's used up as you go. If you need two passes, create the generator twice or materialize a list.
Generator expressions
The list comprehensions of M2 have a lazy sibling: the same syntax with parentheses instead of square brackets:
amounts = [float(r["amount"]) for r in rows] # list: materializes
amounts = (float(r["amount"]) for r in rows) # generator: on demandAnd when the expression is a function's only argument, the function's parentheses are enough. This is the heart of an efficient till close:
No intermediate list: sum() requests values through the iteration protocol and the generator produces them one by one. It's the idiomatic way to aggregate (sum, max, min, any, all) with no memory cost.
Files line by line: the natural generator
Here everything connects with M6: an open file is already a generator of lines. When you wrote for line in f: you weren't loading the file — you were asking for it line by line. Wrapping it in a generator of your own lets you add logic:
from pathlib import Path
BASE = Path(__file__).parent
def read_sales(path: Path):
"""Yields the rows of sales.csv one at a time, as dicts."""
import csv
with path.open(encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
yield row # pauses here; the file stays open
for row in read_sales(BASE / "data" / "sales.csv"):
print(row["title"], row["amount"])An important detail: the inner with keeps the file open while the generator is alive, and closes it when the body finishes (or the generator is destroyed). The consumer doesn't manage the file: it just asks for rows.
Chaining generators: the sales pipeline
The real power appears when you chain generators: each stage consumes from the previous one and produces for the next, like an assembly line where each row travels the whole chain before the next one enters:
def parse_rows(rows):
"""dict → (date, title, amount as float). Skips corrupt rows, like close_till."""
import logging
logger = logging.getLogger(__name__)
for row in rows:
try:
yield row["date"], row["title"], float(row["amount"])
except (KeyError, ValueError):
logger.warning("corrupt row skipped: %r", row) # the WARNING from 07-05
def only_from_day(sales, date):
for d, title, amount in sales:
if d == date:
yield d, title, amount
# Assembling the pipeline: read → parse → filter → sum
rows = read_sales(BASE / "data" / "sales.csv")
sales = parse_rows(rows)
days = only_from_day(sales, "2026-07-13")
total = sum(amount for _, _, amount in days)
print(f"Day's takings: {total:.2f} EUR")Up to the last line not a single row has been read: assembling the pipeline is free. It's sum() that pulls the chain: it asks days for a value, which asks sales, which asks rows, which reads one line from disk. Memory: one row. And each stage is small, named, and testable in isolation — something we'll appreciate in M9.
yield from, briefly
When a generator just wants to delegate to another iterable, yield from avoids the loop:
def sales_from_both_files(current_path, old_path):
yield from read_sales(old_path) # produces everything from the first...
yield from read_sales(current_path) # ...and then everything from the secondyield from iterable is equivalent to for x in iterable: yield x. That's enough for now; it has deeper uses we don't need.
itertools: two useful tools
The standard library (M3) brings itertools, a box of parts for iterators. Two that fit today:
from itertools import islice, chain
# islice: "slicing" a generator (you can't do g[:5])
first_five = islice(read_sales(path), 5) # the first 5 rows, without reading more
# chain: chaining iterables — same as sales_from_both_files, in one line
everything = chain(read_sales(old_path), read_sales(current_path))Both return, of course, lazy iterators. When you catch yourself writing "plumbing" iteration logic, first check whether itertools already ships it.
When a list and when a generator
- Generator when: the data is traversed once and aggregated (
sum, the close's reports), the source is large or of unknown size (sales.csv,papyrus.log), or you're building a staged pipeline. - List when: you need
len(), indexing, sorting (sortedmaterializes anyway), multiple traversals, or the data is small and you want to see it while debugging. The Papyrus catalog (4 books) asdict[str, Book]is perfect as it is: laziness gains nothing on 4 elements.
Common Mistakes and Tips
- Reusing an exhausted generator: the second pass returns "nothing" without an error. If a
sum()gives you an inexplicable 0, check whether you already consumed the generator. - Expecting the body to run when called:
read_sales(path)doesn't open the file yet; it doesn't even validate the path. TheFileNotFoundErrorwill fire on the firstnext(), perhaps far from the call site — keep that in mind when reading tracebacks (M7). returnwith a value inside a generator: it doesn't "return" to the consumer; it ends the generator (the value travels hidden insideStopIteration). To produce values, alwaysyield.- Materializing by accident:
list(generator),sorted(generator)or", ".join(generator)load everything into memory. Sometimes that's the right call — but make it a decision, not an accident. - Tip: annotate your generators with the hints from 08-01 — the simple form is
Iterator[SaleRow](fromtypingorcollections.abc): it documents that it's consumed once. - Tip: if you need to see the contents while debugging,
list(islice(g, 10))shows you a sample without swallowing the whole file.
Exercises
- Write the generator
amounts_for_title(path, title)that yields the amounts (float) of every sale of a given title, readingsales.csvrow by row, and use it withsum()to find out how much Hamlet has taken in total. Reuseread_sales. - Ana wants the first 3 sales over 15.00 EUR for a promotion. Solve it with a generator expression plus
islice, with no intermediate list at all. - Add to the lesson's pipeline a stage
with_vat_breakdown(sales)that receives(date, title, amount)tuples and yields(date, title, amount, vat)wherevat = round(amount - amount / 1.04, 2)(the BOOK_VAT portion contained in the amount). Fit it betweenparse_rowsandonly_from_day, and explain why the order of the stages doesn't change the memory used.
Solutions
-
def amounts_for_title(path, title): for row in read_sales(path): if row["title"] == title: yield float(row["amount"]) total_hamlet = sum(amounts_for_title(BASE / "data" / "sales.csv", "Hamlet"))Two chained generators (
read_sales→amounts_for_title) and one aggregation: constant memory. If some row has"free"as its amount,float()will raise — you can protect it with the minimaltryfrom 07-02, asparse_rowsdoes. -
from itertools import islice big_ones = (float(r["amount"]) for r in read_sales(path) if float(r["amount"]) > 15.00) first_three = list(islice(big_ones, 3))The elegant part: as soon as
islicedelivers the third one, it stops asking — the rest of the file isn't even read. The finallist()materializes only 3 elements, so you can look at them. -
def with_vat_breakdown(sales): for date, title, amount in sales: vat = round(amount - amount / 1.04, 2) yield date, title, amount, vat rows = read_sales(BASE / "data" / "sales.csv") sales = with_vat_breakdown(parse_rows(rows)) days = only_from_day_4(sales, "2026-07-13") # adapted to 4-tuplesMemory doesn't depend on the number of stages but on how many rows are "in transit" at once: exactly one, because each
next()from the consumer drags a single row through the whole chain. Ten stages would still use one row's worth of memory.
Conclusion
Generators fulfill the second promise from the M7 close: close_till can now process the sales without loading them wholesale into memory, with yield pausing and resuming the function, StopIteration closing the cycle that for has always managed, and a read → parse → filter → sum pipeline where each stage does one thing. And in read_sales an old acquaintance was left in plain sight, quietly working: the with, which keeps the file open exactly as long as needed and closes it at the end. We've been using it blindly since module 6, promising to explain it. The next lesson settles that debt: what with does under the hood — the __enter__/__exit__ protocol — and how to write your own context managers, including a yield-based version that will feel suspiciously familiar.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
