AI SWE Prep
0/9in Python Foundations
Python Foundations

Generators & Lazy Evaluation

Stream data instead of materializing it. yield, generator pipelines, and why laziness matters when the data does not fit in memory.

~13 minLesson 6 of 60
Your progressNot started

Most Python code starts by building lists. That is fine until the list is a million log lines, a stream of model responses, or an infinite sequence. A generator lets you describe the next value without materializing every value.

Laziness is a control lever: lower memory, earlier results, and pipelines that can process data as it arrives. The cost is that a generator is one-shot. Once you consume it, it is gone.

The mental model

A generator function is a function that contains yield. Calling it does not run the body immediately. It returns a generator object. Each next() resumes the function until the next yield, pauses there, and remembers its local state.

def countdown(n):
    while n > 0:
        yield n
        n -= 1

nums = countdown(3)      # body has not run yet
next(nums)               # 3
next(nums)               # 2
list(nums)               # [1]

That pause/resume behavior is why generators can represent huge or infinite streams. They only hold the current frame and whatever local state the function needs.

Generator functions vs generator expressions

A generator expression is the lazy cousin of a list comprehension:

squares_list = [n * n for n in range(1_000_000)]
squares_gen = (n * n for n in range(1_000_000))

The list computes and stores every square now. The generator expression computes one square each time something asks for it. Use generator expressions for simple map/filter shapes; use generator functions when the logic needs names, branches, try/finally, or multiple yield points.

def non_empty_lines(path):
    with open(path) as f:
        for line in f:
            stripped = line.strip()
            if stripped:
                yield stripped

The file object is already lazy. This function keeps the laziness and adds a small transformation.

Lazy pipelines

Generators compose. Each stage consumes an upstream iterable and yields a downstream value. Nothing runs until the final consumer asks for data.

def strip_comments(lines):
    for line in lines:
        line = line.split("#", 1)[0].strip()
        if line:
            yield line


def parse_numbers(lines):
    for line in lines:
        yield int(line)

numbers = parse_numbers(strip_comments(open("values.txt")))
total = sum(numbers)

This is the shape behind preprocessing pipelines: read, normalize, filter, chunk, embed. Each stage stays testable because it accepts any iterable, not just a file.

Eager list vs lazy generatortake first 3 even squares
Eager [x*x for x in src]
123456789101112

Touched 12 / 12 source items — all of them, before returning 3.

41636
Lazy (x*x for x in src)
123456789101112

Touched 0 / 12 source items so far.

press “Pull next”
computednever touchedSame result; the generator does strictly less work.

itertools: the lazy toolbox

The itertools module gives you fast, memory-efficient building blocks:

from itertools import count, islice, pairwise

first_five = list(islice(count(start=10, step=2), 5))
# [10, 12, 14, 16, 18]

for before, after in pairwise([3, 8, 10]):
    print(after - before)

Reach for it before writing a custom loop for slicing, chaining, grouping, counting forever, or taking while a predicate is true. These tools are lazy by default, which keeps the pipeline streaming.

Exhaustion is real

A generator is not a reusable collection. It is a cursor over work that has not happened yet.

items = (n for n in range(3))
list(items)      # [0, 1, 2]
list(items)      # []

If you need to iterate twice, either materialize once with list() or create a fresh generator each time. Treat this as a design decision, not an accident.

When to reach for this

Variations

Try it

These exercises are about preserving laziness. Mentally ask, “At this line, how many values are actually in memory?”

ExerciseEasy
  • yield
  • Streaming

Read a file in chunks

Write read_chunks(path, size) so callers can process a large binary file a piece at a time. It should stop cleanly at EOF and close the file even if the caller breaks early.

Approach. Put the open inside the generator function and use a with block. Each yield hands one bytes chunk to the caller; when iteration ends, the context manager closes the file.

Show solution
def read_chunks(path: str, size: int = 1024 * 1024):
    with open(path, "rb") as f:
        while True:
            chunk = f.read(size)
            if not chunk:
                return
            yield chunk


for chunk in read_chunks("events.bin", size=4096):
    process(chunk)

This uses constant memory with respect to file size. The only bytes held by the reader are the current chunk. The with block still runs its cleanup when the generator is closed.

ExerciseMedium
  • Infinite iterator
  • itertools

Build an infinite counter safely

Create a counter that yields start, then adds step forever. Show how to take only the first five values without hanging the program.

Approach. Infinite generators are fine when the consumer is bounded. Use a custom generator to see the mechanics, then itertools.islice to cap the read.

Show solution
from itertools import islice


def count_from(start: int = 0, step: int = 1):
    current = start
    while True:
        yield current
        current += step


first_five = list(islice(count_from(start=10, step=3), 5))
assert first_five == [10, 13, 16, 19, 22]

The generator itself never ends. The safety comes from islice, which asks for exactly five values and then stops. Never call list() on an unbounded generator without a limiting wrapper.

GotchaHard
  • Pipeline
  • Exhaustion

Fix a consumed pipeline

You have a lazy pipeline of text lines. You need both the sum of parsed numbers and a preview of the first three parsed values. Why does the preview come out empty, and how should you structure it?

numbers = (int(line) for line in lines if line.strip())
total = sum(numbers)
preview = list(numbers)[:3]    # []

Approach. sum(numbers) consumes the generator. If you need two passes, make a factory that creates a fresh generator, or materialize once if the data is small enough.

Show solution
def parsed_numbers(lines):
    for line in lines:
        line = line.strip()
        if line:
            yield int(line)


def make_numbers():
    return parsed_numbers(["10", "", "20", "30", "40"])


total = sum(make_numbers())
preview = []
for value in make_numbers():
    preview.append(value)
    if len(preview) == 3:
        break

assert total == 100
assert preview == [10, 20, 30]

A generator is a one-shot cursor. A generator function is reusable because it creates a new cursor each time. If the source is a file, reopen it or deliberately store the needed subset while streaming.

The classic pitfall

Your progressNot started