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

  1. The problem: loading everything vs processing on demand
  2. yield: functions that pause
  3. The iteration protocol: next() and StopIteration
  4. Generators vs lists: the truth table
  5. Generator expressions
  6. Big files line by line: the natural generator
  7. Chaining generators: the sales pipeline
  8. yield from, briefly
  9. itertools: islice and chain
  10. 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 amounts

Two 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 ... StopIteration

Each 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:

for sale in first_sales():
    print(sale)       # the for handles next() and StopIteration for you

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 anything

A 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 demand

And when the expression is a function's only argument, the function's parentheses are enough. This is the heart of an efficient till close:

total = sum(float(r["amount"]) for r in rows if r["date"] == today)

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 second

yield 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 (sorted materializes anyway), multiple traversals, or the data is small and you want to see it while debugging. The Papyrus catalog (4 books) as dict[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. The FileNotFoundError will fire on the first next(), perhaps far from the call site — keep that in mind when reading tracebacks (M7).
  • return with a value inside a generator: it doesn't "return" to the consumer; it ends the generator (the value travels hidden inside StopIteration). To produce values, always yield.
  • 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] (from typing or collections.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

  1. Write the generator amounts_for_title(path, title) that yields the amounts (float) of every sale of a given title, reading sales.csv row by row, and use it with sum() to find out how much Hamlet has taken in total. Reuse read_sales.
  2. 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.
  3. Add to the lesson's pipeline a stage with_vat_breakdown(sales) that receives (date, title, amount) tuples and yields (date, title, amount, vat) where vat = round(amount - amount / 1.04, 2) (the BOOK_VAT portion contained in the amount). Fit it between parse_rows and only_from_day, and explain why the order of the stages doesn't change the memory used.

Solutions

  1. 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_salesamounts_for_title) and one aggregation: constant memory. If some row has "free" as its amount, float() will raise — you can protect it with the minimal try from 07-02, as parse_rows does.

  2. 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 islice delivers the third one, it stops asking — the rest of the file isn't even read. The final list() materializes only 3 elements, so you can look at them.

  3. 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-tuples
    

    Memory 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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved