Generators and the yield Keyword

Produce values one at a time with yield

Intermediate 12 min

In this lesson

A generator produces values one at a time, only as you ask for them — using the yield keyword instead of return — rather than building a whole list in memory at once. That makes generators ideal for scripts that stream through big files or long sequences without loading everything into memory. You met generator expressions briefly; here you’ll write full generator functions and see exactly how yield works.

Explain it like I’m 5

A normal function hands you a finished basket of fruit. A generator hands you one piece each time you reach out — it pauses in between and only picks the next piece when you ask.

From return to yield

Put yield in a function and it becomes a generator function. Where return ends a function and hands back one value, yield hands back a value and pauses the function in place — keeping all its variables. The next time you ask for a value, it resumes right after the yield, remembering exactly where it was.

Example
def count_up_to(limit):
    n = 1
    while n <= limit:
        yield n
        n += 1

for number in count_up_to(3):
    print(number)
Output
1
2
3
Each loop pass resumes the generator right after its yield.

Lazy by default, and one-shot

Calling a generator function doesn’t run it. It hands back a generator object — itself a kind of iterator — and the body only runs as you pull values out, with next(), a for loop, or something like list() or sum(). That’s why generators are called lazy: nothing happens until you ask.

Example
def announce(items):
    print("generator starting")
    for item in items:
        yield item

gen = announce(["a", "b"])
print("made the generator, but nothing has run yet")
print(next(gen))
print(next(gen))
Output
made the generator, but nothing has run yet
generator starting
a
b
The body doesn’t run until the first next().
Example
def squares(nums):
    for n in nums:
        yield n * n

gen = squares([1, 2, 3])
print(next(gen))
print(next(gen))
print(list(gen))
print(list(gen))
Output
1
4
[9]
[]
A generator is consumed once, then it’s empty.

The walrus operator := (assignment expression)

Python 3.8 added the walrus operator :=, also called an assignment expression. It assigns a value and returns it in the same step, which lets you write certain loops more cleanly.

The classic use case is a while loop that reads data in chunks: you want to read, check whether you got anything, and use the result — all without calling .read() twice or using a separate assignment line.

Example
# Without := you need two lines or a read-twice pattern.
chunk = f.read(1024)
while chunk:
    process(chunk)
    chunk = f.read(1024)

# With := -- read, assign, and test in one expression.
while chunk := f.read(1024):
    process(chunk)
:= reads and assigns in one step, so f.read() is only called once per iteration.
Example
# := works in any expression context, not just while loops.
data = [1, 2, 3, 4, 5, 6]

# Process pairs until fewer than 2 items remain.
while (n := len(data)) >= 2:
    a, b = data.pop(0), data.pop(0)
    print(f"pair: {a}, {b}  (remaining: {n - 2})")
Output
pair: 1, 2  (remaining: 4)
pair: 3, 4  (remaining: 2)
pair: 5, 6  (remaining: 0)
:= can capture any expression result — here the length of a shrinking list.

The real win: stream data without loading it all

This is where generators earn their keep in real scripts. Python’s file objects are already lazy — looping path.open() line by line never loads more than one line at a time. A generator wrapping that lets you filter or transform on the fly, and the whole pipeline still holds only one line in memory.

For binary files (ZIPs, images, PDFs, large CSV exports) the approach is the same but you read fixed-size chunks instead of lines. Combining pathlib.Path with yield keeps memory flat no matter how large the file is.

Example
from pathlib import Path
from typing import Iterator

def stream_lines(path: str | Path) -> Iterator[str]:
    file_path = Path(path)
    with file_path.open("r", encoding="utf-8") as f:
        for line in f:
            yield line

def error_lines(path: str | Path) -> Iterator[str]:
    for line in stream_lines(path):
        if "ERROR" in line:
            yield line.strip()

# --- stand-in for a real file ---
from pathlib import Path
p = Path("app.log")
p.write_text("INFO   all good\nERROR  disk full\nINFO   all good\nERROR  timeout\n")

for line in error_lines(p):
    print(line)
Output
ERROR  disk full
ERROR  timeout
stream_lines yields one line at a time; error_lines filters on the fly.
Example
from pathlib import Path
from typing import Iterator

def stream_file(path: str | Path, chunk_size: int = 4) -> Iterator[bytes]:
    file_path = Path(path)
    with file_path.open("rb") as f:
        while chunk := f.read(chunk_size):
            yield chunk

# --- stand-in for a real binary file ---
p = Path("data.bin")
p.write_bytes(b"ABCDEFGHIJ")

for chunk in stream_file(p):
    print(chunk)
Output
b'ABCD'
b'EFGH'
b'IJ'
Binary mode with a chunk loop — the file is never fully in memory.

Finish stream_chunks so it yields one chunk_size-byte chunk at a time without reading the whole object into memory. BytesIO behaves exactly like Path('large-file.bin').open('rb') — swap it in and nothing else changes.

from io import BytesIO

# Stand-in for Path("large-file.bin").open("rb") -- swap it in and nothing else changes.
data = BytesIO(b"ABCDEFGHIJ")

def stream_chunks(file_obj=data, chunk_size=4):
    # TODO: yield one chunk at a time without reading everything at once
    pass

for chunk in stream_chunks():
    print(chunk)

Chaining generators into a pipeline

Because every generator both consumes an iterable and produces one, you can stack them: each stage does one job — clean, filter, transform — and passes items along lazily. yield from is a shortcut for ‘yield every item of that iterable in turn’, handy for delegating to another source.

Example
def clean(lines):
    for line in lines:
        yield line.strip()

def to_numbers(lines):
    for line in lines:
        if line.isdigit():
            yield int(line)

raw = ["10\n", "oops\n", "20\n", "  30  \n"]
print(sum(to_numbers(clean(raw))))
Output
60
clean → to_numbers → sum, all lazy end to end.
Example
def chain(*iterables):
    for it in iterables:
        yield from it

for value in chain([1, 2], (3, 4), "ab"):
    print(value)
Output
1
2
3
4
a
b
yield from re-yields every item of each source in turn.

Write a generator running_total(numbers) that yields the total so far after each number. For [1, 2, 3] it should produce 1, 3, 6.

def running_total(numbers):
    total = 0
    # TODO: add each number to total, then yield the new total
    pass

print(list(running_total([1, 2, 3])))

Generator functions vs. generator expressions

For a quick one-liner, a generator expression — a comprehension in parentheses — is perfect. Reach for a full generator function when the logic needs more than one expression: multiple steps, state that carries between items, early stops, or yield from. They produce the same kind of lazy, one-shot generator.

Example
numbers = [1, 2, 3, 4, 5]

# Generator expression — parentheses, no function needed:
gen = (n * n for n in numbers)
print(sum(gen))

# The same idea written as a generator function:
def squares(nums):
    for n in nums:
        yield n * n

print(sum(squares(numbers)))
Output
55
55
Same result; the function form scales to richer logic.

Common mistake: Reusing a generator after it’s exhausted

Why it happens:

After one full pass (a for loop, list(), or sum()), the generator is empty. Looping it again silently does nothing, which looks like missing data.

How to fix it:

Call the generator function again to get a fresh one, or store the values in a list() once if you genuinely need several passes.

Common mistake: Using return instead of yield to send back values

Why it happens:

Old habits: a return value inside a generator does not hand that value to the loop — it just stops the generator.

How to fix it:

yield each value you want to produce. Use a bare return only when you want to end the generator early.

Common mistake: Calling len() or indexing a generator

Why it happens:

A generator produces items on demand, so it has no length and no gen[0] — those raise TypeError.

How to fix it:

If you truly need length or random access, convert with list(gen) first (you lose the lazy memory savings). Otherwise just iterate it.

What does calling a generator function, like count_up_to(3), actually do?

You loop a generator fully, then loop it again. What happens the second time?

When is a generator the better choice over a list?

Mini exercise (medium)

Write a generator first_words(lines) that yields the first word of each non-blank line. Strip each line first, and skip lines that are empty or only spaces.

Do it here. Finish the code below and press Run to try your answer right in the browser — no setup needed.

def first_words(lines):
    # TODO: for each non-blank line, yield its first word (stripped)
    pass

print(list(first_words(["hello world", "  ", "foo bar baz"])))

What to learn next

You saw how yield turns a function into a generator that pauses and resumes, why generators are lazy and one-shot, how to stream large files without loading them, and how to chain generators into pipelines with yield from. You wrote a first_words() generator.

Before you trust a file-changing script, you need save points and a way to check it. Git Checkpoints and Testing covers small commits as safe checkpoints and simple assert tests for your helpers. When you’re ready, continue to the next lesson.