Python Primer·Chapter 8

Iterators, Generators and Context Managers

How Python processes things one at a time without loading everything at once, and the with statement that guarantees cleanup. This is the chapter that lets you read a file bigger than your memory.

Two ideas here, and both are about resources.

The first is laziness: producing values one at a time, on demand, rather than building the whole collection up front. That is what lets you process a fifty gigabyte file on a laptop.

The second is cleanup: making sure files get closed and connections get released, even when something goes wrong halfway through.

Iteration, Underneath

When you write a for loop, Python does something specific:

for x in [1, 2, 3]:
    print(x)

It asks the list for an iterator, then repeatedly asks that iterator for the next value until there are none left. You can do it by hand:

it = iter([1, 2, 3])
next(it)      # 1
next(it)      # 2
next(it)      # 3
next(it)      # StopIteration

You will rarely write that. But knowing the loop works this way explains two things that otherwise seem arbitrary.

An iterator is used up. Once exhausted, it is finished:

squares = (n ** 2 for n in range(3))     # a generator expression

list(squares)     # [0, 1, 4]
list(squares)     # []   ← already consumed

That catches people constantly. If you need the values twice, make a list.

Anything can be iterable. A list, a string, a dictionary, a file, a database cursor, a network response. The loop does not care where the values come from, which is why the same syntax reads a file and a list.

Generators

A generator is a function that produces values one at a time. Instead of return, it uses yield.

def count_up_to(n):
    i = 1
    while i <= n:
        yield i          # hand out a value and PAUSE here
        i += 1

for x in count_up_to(3):
    print(x)             # 1, 2, 3

When the function hits yield, it hands back a value and freezes. Its local variables stay exactly as they were. When the next value is requested, it resumes on the following line.

flowchart LR
    subgraph EAGER["A normal function"]
        E1["build the whole list"] --> E2["return it"] --> E3["all of it in memory"]
    end
    subgraph LAZY["A generator"]
        L1["yield one value"] --> L2["pause, keep state"]
        L2 -->|"next value asked for"| L1
        L2 --> L3["one value in memory<br/>at a time"]
    end
    style EAGER fill:#f0efe9,stroke:#5c6670
    style LAZY fill:#eef1fc,stroke:#2141c8

The difference in memory is the entire point:

import sys

eager = [n ** 2 for n in range(1_000_000)]      # square brackets: a list
lazy  = (n ** 2 for n in range(1_000_000))      # parentheses: a generator

sys.getsizeof(eager)     # about 8,000,000 bytes
sys.getsizeof(lazy)      # about 200 bytes

Both produce the same values. One holds a million numbers; the other holds a recipe.

Reading a large file

This is the pattern that matters, and it is why the chapter exists.

# Loads the whole file into memory. Fails on a large one.
with open('huge.csv') as f:
    lines = f.readlines()
for line in lines:
    process(line)

# Reads one line at a time. Works on any size.
with open('huge.csv') as f:
    for line in f:
        process(line)

A file object is already an iterator over its lines. The second version never holds more than one line, and it is also shorter.

Chaining generators lets you build a pipeline where nothing is materialised:

def read_lines(path):
    with open(path) as f:
        for line in f:
            yield line.rstrip('\n')

def parse(lines):
    for line in lines:
        yield line.split(',')

def valid_only(rows):
    for row in rows:
        if len(row) == 5:
            yield row

for row in valid_only(parse(read_lines('huge.csv'))):
    ...      # one row flows through the whole chain at a time

Each stage pulls from the one before it on demand. Memory use stays flat regardless of file size.

When not to use one

Generators are not free of downsides:

  • You cannot get the length without consuming it.
  • You cannot index into it. No gen[5].
  • It is single use.

If the data comfortably fits in memory and you need it more than once, use a list. Laziness is for when size or cost makes it necessary.

itertools

The standard library has a module of iterator tools worth knowing exists.

from itertools import islice, chain, groupby, product, count

# Take the first n items from anything, including an infinite generator
first_five = list(islice(some_generator, 5))

# Treat several sequences as one
for x in chain([1, 2], [3, 4]):
    ...

# Every combination of two lists
for city, year in product(['Delhi', 'Sydney'], [2023, 2024]):
    ...

# Count without end — useful with islice or a break
for i in count(start=1):
    if done: break

islice is the one you will reach for most. It is how you peek at the first few rows of an enormous file without reading it all:

with open('huge.csv') as f:
    for line in islice(f, 5):
        print(line)

Context Managers

The other half of this chapter. A with block guarantees that cleanup happens.

with open('data.csv') as f:
    data = f.read()
# the file is closed here, guaranteed

The guarantee holds even if an exception is raised inside the block. Compare with the manual version:

f = open('data.csv')
data = f.read()          # if this raises, the next line never runs
f.close()                # and the file stays open

Leaked file handles are a real problem in long-running processes. The operating system limits how many you can have open, and a script that leaks one per iteration will eventually fail with a message that does not obviously point at the cause.

You will meet with in several places:

with open('out.csv', 'w') as f:            # files
    ...

with sqlite3.connect('data.db') as conn:   # database connections
    ...

with pd.ExcelWriter('report.xlsx') as w:   # writing multiple sheets
    df1.to_excel(w, sheet_name='Summary')
    df2.to_excel(w, sheet_name='Detail')

Several at once, in one statement:

with open('in.csv') as src, open('out.csv', 'w') as dst:
    for line in src:
        dst.write(line.upper())

Writing your own

The easiest way is a decorated generator. Everything before the yield is setup, everything after is cleanup, and the cleanup runs no matter what.

from contextlib import contextmanager
import time

@contextmanager
def timed(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label}: {time.perf_counter() - start:.3f}s")

with timed("loading data"):
    df = pd.read_csv('big.csv')
# loading data: 2.417s

That try/finally is what makes the guarantee work. finally always runs, whether the block completed or raised.

This pattern is genuinely useful in analysis code. A timing context manager around each stage of a pipeline tells you where the time goes with almost no effort.

Putting It Together

A realistic pattern combining everything: process a file too large to load, in batches, with timing.

from itertools import islice
from contextlib import contextmanager
import time

@contextmanager
def timed(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label}: {time.perf_counter() - start:.2f}s")

def batched(iterable, size):
    """Yield lists of `size` items from any iterable."""
    it = iter(iterable)
    while batch := list(islice(it, size)):
        yield batch

with timed("processing"), open('huge.csv') as f:
    next(f)                                  # skip the header
    for batch in batched(f, 10_000):
        rows = [line.split(',') for line in batch]
        # ... handle 10,000 rows at a time

The := in that while loop is the walrus operator. It assigns and tests in one step, so the loop runs while batched is still producing non-empty lists.

Memory use stays at ten thousand rows regardless of whether the file has a thousand rows or a billion.

What Carries Forward

A generator produces values on demand. Parentheses instead of square brackets turns a list comprehension into one.

Iterating a file gives you lines one at a time. This is how you handle files larger than memory, and it is also the shorter way to write it.

Iterators are consumed once. If you need the values twice, build a list.

Always open files with with. The cleanup guarantee holds even when something raises.

Next: errors, modules, and the parts of the standard library that come up constantly in data work.