Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+200 XP

Generators and yield

How yield actually pauses and resumes a function, generator expressions, memory-efficient lazy evaluation, and yield from.

50 min August 2026
// Part 01 — What a Generator Function Is

A Function With yield Does Not Return a Value — It Returns a Generator

Module 27 ended with a promise: everything you built by hand with DateRangeIterator — a class implementing __iter__ and __next__, tracking its own state between calls — Python can give you almost for free, with a single keyword. That keyword is yield.

A generator function is any function whose body contains at least one yield statement. Calling it does not run the function body at all — it immediately returns a generator object, which is a real, genuine iterator, automatically satisfying the entire iterator protocol from Module 27 without you writing a single __next__ method.

Calling a generator function does not run its body
def count_up_to(limit):
    print("Starting the count!")
    n = 1
    while n <= limit:
        yield n
        n += 1

gen = count_up_to(3)
print(gen)   # <generator object count_up_to at 0x...> — no "Starting the count!" printed yet!
print(type(gen))   # <class 'generator'>

Nothing inside count_up_to has executed yet — not even the print("Starting the count!") on the very first line. Calling a generator function only creates the generator object; the body starts running only once you begin pulling values from it, which is the entire subject of Part 02.

// Part 02 — How yield Pauses and Resumes

The Mechanism That Trips Up Almost Everyone at First

Here is the part that takes real effort to internalize, because nothing else in Python behaves quite like it. When you call next() on a generator, the function body starts running from the top — and runs until it hits a yield statement. At that exact point, execution pauses, the yielded value is handed back to whoever called next(), and — critically — every local variable and the exact line the function was on are frozen in place, preserved completely. The next call to next() does not restart the function; it resumes execution exactly where it left off, right after that yield.

Watching the pause-and-resume mechanism directly
gen = count_up_to(3)

print(next(gen))
# Starting the count!     <- the function body finally starts running
# 1                        <- runs until "yield n" with n=1, then PAUSES here

print(next(gen))
# 2                        <- RESUMES right after "yield", runs "n += 1", loops, hits yield again

print(next(gen))
# 3                        <- same thing again

print(next(gen))
# StopIteration            <- the while loop condition is now False, function falls off the end

This is fundamentally different from a normal function call, where every call starts fresh from line one with no memory of any previous call. A generator function's local state — every variable, its current position in a loop, everything — survives between calls to next(), held in suspended animation. This is, under the hood, exactly how the Python interpreter implements __next__ for you automatically: it is genuinely running your function's bytecode, pausing it mid-execution, and resuming it later, something ordinary function calls simply cannot do.

🎯 Pro Tip
A useful mental model: think of yield as a return statement that leaves a bookmark. It hands back a value like return does, but instead of the function ending and forgetting everything, it bookmarks the exact spot, and the next next() call picks the bookmark back up and keeps going as if nothing happened in between.

Once the function body reaches its natural end — falls off the bottom, or hits an explicit return with no value — the generator raises StopIteration, exactly like any other exhausted iterator from Module 27. A return statement inside a generator does not send back a normal return value the way it would in a regular function; it simply ends the generator.

// Part 03 — Generator Expressions

The Lazy Cousin of the List Comprehension

Module 12 covered list comprehensions in depth: [x**2 for x in range(10)] builds the entire list immediately, in memory, all at once. A generator expression uses nearly identical syntax — parentheses instead of square brackets — but produces values lazily, one at a time, exactly like a generator function does.

List comprehension vs generator expression — syntax and behaviour
squares_list = [x**2 for x in range(1_000_000)]     # built ENTIRELY, right now, in memory
squares_gen  = (x**2 for x in range(1_000_000))     # produces nothing yet — lazy

print(type(squares_list))   # <class 'list'>
print(type(squares_gen))    # <class 'generator'>

print(next(squares_gen))    # 0   — the FIRST value, computed only now
print(next(squares_gen))    # 1
print(next(squares_gen))    # 4

Every rule from Module 27 about iterators applies directly: a generator expression is a single-pass iterator, exhausted after one full loop, and cannot be rewound or reused. It also supports every comprehension feature you already know — an if filter clause, nested loops, and multiple for clauses — the syntax carries over completely.

A filtered generator expression — same syntax as a list comprehension
# Only even squares, computed lazily
even_squares = (x**2 for x in range(20) if x % 2 == 0)
print(list(even_squares))   # [0, 4, 16, 36, 64, 100, 144, 196, 256, 324]
💡 Note
A genuinely useful shortcut: a generator expression passed as the sole argument to a function does not need its own parentheses — sum(x**2 for x in range(10)) works directly, without writing sum((x**2 for x in range(10))). This is extremely common in real code with sum(), any(), all(), max(), and min(), since none of them need the intermediate values stored anywhere — they consume the generator one value at a time as they go.
// Part 04 — Memory Efficiency

Why This Actually Matters — A Concrete Memory Comparison

This is not an abstract, academic distinction. Imagine processing a 4 GB production log file, extracting every line that contains the string "ERROR". A list comprehension approach loads the entire file into memory before you can even start looking at the first error line. A generator processes the file one line at a time, holding only the current line in memory, no matter how large the file is.

The list comprehension approach — loads everything, all at once
def get_error_lines_list(filepath):
    with open(filepath) as f:
        return [line for line in f if "ERROR" in line]
        # Every single line of the file is read AND kept in memory before this
        # function even returns — for a 4 GB file, that is roughly 4 GB of RAM.
The generator approach — one line in memory at a time, regardless of file size
def get_error_lines_gen(filepath):
    with open(filepath) as f:
        for line in f:
            if "ERROR" in line:
                yield line
        # At any given moment, only the current line exists in memory.
        # A 4 GB file and a 4 MB file consume roughly the SAME peak memory here.

for error_line in get_error_lines_gen("app.log"):
    process(error_line)   # each line is handled and then can be garbage collected

The trade-off is real, not free: the generator version is typically slightly slower for small inputs, because of the pause/resume overhead on every single value, and it cannot be indexed, sliced, or looped over twice. But for anything genuinely large — log files, database result sets, API pagination, huge CSVs — the memory savings are not a minor optimization; they are frequently the difference between a script that runs and a script that gets killed by the operating system for exhausting available memory.

⚠️ Important
A generator does not make a slow operation fast. It changes when work happens (spread out, one item at a time) and how much memory is held at once — it does not reduce the total amount of computation. If you need every value processed regardless, a generator and a list will eventually do the same total work; the generator just never needs to hold all the results at once.
// Part 05 — yield from

Delegating to a Sub-Generator

yield from hands off iteration to another iterable or generator entirely, yielding every value it produces in turn — without writing a manual for ... yield loop around it. It is syntactic sugar, but genuinely useful sugar that shows up constantly once generators start composing with each other.

Without yield from — a manual loop
def chain_manual(*iterables):
    for iterable in iterables:
        for item in iterable:
            yield item

list(chain_manual([1, 2], "ab", (True, False)))
# [1, 2, 'a', 'b', True, False]
With yield from — the same behaviour, more directly
def chain_delegated(*iterables):
    for iterable in iterables:
        yield from iterable

list(chain_delegated([1, 2], "ab", (True, False)))
# [1, 2, 'a', 'b', True, False] — identical result

This is genuinely more than a shorthand for a nested loop once you have generators calling other generators — a common shape when a large task is naturally broken into smaller sub-tasks, each expressed as its own generator function.

A generator delegating to sub-generators, each handling one piece of work
def read_section(name, rows):
    for row in rows:
        yield f"[{name}] {row}"

def read_full_report():
    yield from read_section("summary", ["total: 1200", "errors: 3"])
    yield from read_section("details", ["row A", "row B"])

for line in read_full_report():
    print(line)
# [summary] total: 1200
# [summary] errors: 3
# [details] row A
# [details] row B
// Part 06 — Worked Example

Lazily Reading a Huge CSV File

Modules 15 and 16 covered reading files and working with CSV data. Here is where generators make that combination genuinely production-grade: a function that reads a large CSV file and yields one parsed row at a time, using the csv module, never holding the whole file's rows in memory at once.

A lazy CSV row reader, filtering and transforming as it goes
import csv

def read_high_value_orders(filepath, minimum_total):
    with open(filepath, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            total = float(row["total"])
            if total >= minimum_total:
                yield {
                    "order_id": row["order_id"],
                    "customer": row["customer"],
                    "total": total,
                }

# Nothing is read from disk until you actually start pulling values:
for order in read_high_value_orders("orders_2026.csv", minimum_total=500):
    send_to_fulfillment_priority_queue(order)
    # Each row is read from disk, parsed, filtered, and processed — one at a time.
    # A 10-million-row CSV and a 10-row CSV use roughly the same peak memory here.

Notice that this reads exactly like a normal function using a normal for loop — the laziness is invisible at the call site, entirely a consequence of the single yield keyword inside. This is a genuinely important property of generators: the calling code does not need to know or care whether it is looping over a list or a generator — the for order in ... syntax is identical either way, exactly because generators fully satisfy the iterator protocol from Module 27.

// Part 07 — When You Actually Need a List

Generators Are Not Always the Right Tool

Reaching for a generator by default, everywhere, is its own mistake. A list is the right choice whenever you genuinely need any of the things a single-pass iterator cannot give you:

Things that require a list (or another concrete collection), not a generator
data_gen = (x for x in range(10))

len(data_gen)          # TypeError — generators have no length; there is no way to
                        # know how many items remain without consuming them

data_gen[3]              # TypeError — generators cannot be indexed or sliced;
                          # there is no random access, only "the next value"

for x in data_gen: ...   # first pass — fully consumes it
for x in data_gen: ...   # second pass — produces NOTHING; already exhausted

If you need to know how many items there are before processing them, need to access items out of order, or need to loop over the same data more than once, materialize it into a list (or another concrete collection) up front. The honest rule of thumb: use a generator when data is large, processed once, and processed in order — use a list when you need to inspect, index, measure, or revisit it.

🎯 Pro Tip
A common, genuinely good middle ground: write the core logic as a generator, and let the caller decide whether they need a list. results = list(my_generator_function(...)) converts lazily-produced values into a concrete, reusable list exactly when needed — without forcing the generator function itself to choose eagerness for every caller, including the ones who only needed to loop once.
// Part 08 — Real World
💼 What This Looks Like at Work

The Out-of-Memory Kill at a Raleigh Healthcare Data Company

Scenario — Healthcare data company, Raleigh · Production outage

A Raleigh-based healthcare data company runs a nightly job that processes appointment logs from every clinic location, looking for scheduling anomalies. It had worked fine for a year, running against a few hundred thousand rows a night. After onboarding several new hospital systems at once, the nightly job started failing — the container running it was being killed by the cloud provider's out-of-memory monitor partway through.

The original job — worked fine until the data grew
def load_appointments(filepath):
    with open(filepath, newline="") as f:
        reader = csv.DictReader(f)
        return [row for row in reader]   # the ENTIRE file, all at once, in memory

def find_anomalies(filepath):
    appointments = load_appointments(filepath)   # 40M+ rows, now — several GB
    return [a for a in appointments if is_suspicious(a)]

Why it broke, and why it took a week to diagnose

The bug was invisible in code review — nothing about load_appointments looks wrong; it is a completely ordinary list comprehension, exactly the pattern taught in Module 12. The problem only exists at scale: once the combined appointment logs crossed several gigabytes, holding the entire parsed list in memory — on top of the second list comprehension building a filtered copy — exceeded the container's memory limit before the job could finish.

The fix

The team rewrote load_appointments as a generator function, exactly following Part 06's pattern, and changed find_anomalies to consume it lazily instead of materializing a full list at either stage.

The fix — generators end to end, one appointment in memory at a time
def load_appointments(filepath):
    with open(filepath, newline="") as f:
        reader = csv.DictReader(f)
        yield from reader   # yield from Part 05 — delegates row by row

def find_anomalies(filepath):
    for appointment in load_appointments(filepath):
        if is_suspicious(appointment):
            yield appointment   # still lazy — the CALLER decides whether to
                                  # materialize this into a list or stream it further

Peak memory usage dropped from several gigabytes to a few megabytes, and the job's runtime barely changed — the total amount of work was identical, exactly as the Callout in Part 04 explains. The only thing that changed was when each row's memory was allocated and released, which is precisely the trade-off this module is built around.

// Part 09 — Misconceptions

Four Misconceptions About Generators

✕ ""Calling a generator function runs its code immediately, just like a normal function""
Calling a generator function only CREATES a generator object — none of the function body runs until you start pulling values from it with next() or a for loop, as demonstrated in Part 01, where even the first print() statement is deferred.
✕ ""Generators are always faster than building a list""
Generators do not reduce total computation — they change WHEN work happens and how much memory is held at once, not how much work there is overall, as covered directly in Part 04. For small inputs, the pause/resume mechanism actually adds a small amount of overhead compared to a plain list comprehension.
✕ ""A generator, once created, can be looped over as many times as a list can""
A generator is a single-pass iterator, exactly like the iterators from Module 27 — once exhausted, looping over it again produces nothing, silently, with no error. If you need multiple passes, convert it to a list once with list(my_generator) and reuse the resulting list.
✕ ""yield and return do basically the same thing inside a function""
return ends a function immediately and hands back a single value, forgetting all local state. yield pauses the function, hands back one value, and PRESERVES every local variable so execution can resume from that exact point on the next call — a fundamentally different mechanism, not just different syntax for the same idea.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

What is a generator function, and what does calling one actually return?
A generator function is any function containing at least one yield statement. Calling it does not run the function body — it immediately returns a generator object, a genuine iterator satisfying the full iterator protocol (__iter__ and __next__) automatically, without the function author writing either method by hand.
Explain exactly what happens when yield is reached during execution.
Execution pauses at that exact line, the yielded value is handed back to whoever called next(), and every local variable plus the current execution position are preserved. The next call to next() does not restart the function — it resumes execution immediately after the yield statement, continuing with all local state intact, until the next yield, a return, or the function naturally ends (either of which raises StopIteration).
Why are generators more memory-efficient than building a list for large datasets, and what is the actual trade-off?
A list comprehension builds and holds every result in memory at once. A generator produces one value at a time, only holding the current item's state in memory, regardless of how large the overall dataset is. The trade-off is that generators cannot be indexed, sliced, measured with len(), or looped over more than once — and for small datasets, the pause/resume mechanism can be marginally slower than simply building a list upfront.
What is the difference between a generator expression and a list comprehension?
They use nearly identical syntax — parentheses instead of square brackets — and support the same for/if clauses. A list comprehension evaluates eagerly, building the full list immediately. A generator expression evaluates lazily, producing each value only when requested via next() or iteration, and like any generator, is exhausted after a single pass.
What does yield from do, and why would you use it instead of a manual nested for loop?
yield from delegates iteration to another iterable or generator, yielding every value it produces in turn, without writing an explicit "for item in iterable: yield item" loop. It is especially useful when generators compose — a larger generator function delegating to several smaller sub-generators, each handling one piece of a larger task, as shown with read_full_report() delegating to read_section() in Part 05.
// Common Mistakes

Generator Mistakes That Cause Genuinely Confusing Bugs

Trying to call len() or index into a generator
Both raise TypeError — a generator has no way to know its total length without consuming itself, and has no random access to arbitrary positions. If you need either capability, convert it to a list first with list(my_generator).
Reusing a generator object after it has already been fully consumed
Silently produces nothing on the second pass, exactly like any exhausted iterator from Module 27 — no error is raised. If you need to loop over the same data twice, call the generator FUNCTION again to get a fresh generator object, or materialize the results into a list once.
Expecting print() statements or side effects inside a generator to run immediately when it is defined or called
Nothing in a generator function's body executes until you start pulling values from it, as shown in Part 01 — a common source of confusion when debugging code that appears to "do nothing" until a for loop or next() call actually consumes it.
Mixing up when a generator expression needs its own parentheses
sum(x for x in range(10)) works without extra parentheses because the generator expression is the SOLE argument. But list(x for x in range(10), other_arg) needs its own explicit parentheses once there is more than one argument: list((x for x in range(10)), other_arg).
Using return with a value inside a generator function, expecting it to work like a normal function return
A generator's return statement (with or without a value) simply ends the generator, raising StopIteration — it does NOT hand the value back through next() or a for loop the way a return does in a normal function. Values in a generator can only be surfaced through yield.
// Error Library

Errors You Will Hit With Generators — And Exactly Why

TypeError: object of type 'generator' has no len()
Cause: len() was called on a generator object — generators have no concept of a fixed length, since they may produce values lazily, computed on demand, without ever knowing the total count in advance.
Fix: Convert to a list first if you need a count: count = len(list(my_generator())). Better yet, if you only need the count, use sum(1 for _ in my_generator()) to avoid holding every value in memory at once.
TypeError: 'generator' object is not subscriptable
Cause: An attempt to index or slice a generator directly, e.g. my_gen[0] — generators support only sequential access via next(), never random access by position.
Fix: Convert to a list first if indexing is genuinely needed: items = list(my_generator()); items[0]. If only the first value is needed, next(my_generator()) avoids materializing the rest.
RuntimeError: generator raised StopIteration
Cause: A StopIteration exception was raised (often accidentally, from a nested next() call without a default) and allowed to propagate out of a generator function's body — Python 3.7+ deliberately converts this into a RuntimeError, because letting a real StopIteration escape a generator would silently and incorrectly end an enclosing loop.
Fix: Never let a bare StopIteration escape a generator function's body. If calling next() manually inside a generator, always supply a default value: next(inner_iterator, default_value).
ValueError: I/O operation on closed file
Cause: A generator that reads a file lazily (Part 06 style) was only partially consumed, and the "with open(...) as f:" block it lives inside was allowed to close the file before iteration resumed — this happens if the file handle is opened outside the generator instead of inside it.
Fix: Open the file INSIDE the generator function itself, using "with", exactly as shown in Part 06's CSV example, so the file stays open for the generator's entire lifetime, not just until the enclosing function returns.

🎯 Key Takeaways

  • A generator function contains at least one yield statement. Calling it does not run the body — it immediately returns a generator object, a fully-formed iterator, with no __next__ method written by hand.
  • yield pauses execution at that exact line, hands back a value, and preserves every local variable — the next next() call resumes right after the yield, not from the top of the function.
  • A generator expression is the lazy cousin of a list comprehension — same syntax with parentheses instead of brackets, producing values on demand instead of building the full result immediately.
  • Generators trade a small per-value overhead for dramatically lower peak memory use — the right tool for large datasets processed once, in order, exactly as shown in the Raleigh healthcare example.
  • A generator does not reduce total computation — it changes WHEN work happens and how much is held in memory at once, not how much work exists overall.
  • yield from delegates iteration to another iterable or generator, yielding every value it produces — genuinely useful once generators compose, calling other generators for sub-tasks.
  • A generator is a single-pass iterator: no len(), no indexing, no slicing, and no second pass once exhausted. Convert to a list with list(...) when you need any of those capabilities.
  • return inside a generator ends it (raising StopIteration) rather than handing back a value through next() — values can only be surfaced through yield.

What comes next

Module 29 builds decorators from first principles — functions that take a function and return a function — starting from the same "functions as objects" idea that made generators and wrapper functions possible in this module and the last.

Module 29 → Decorators — Writing and Using Them
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...