Home Python Python Generators — The Empty Log Report Bug
Intermediate 9 min · March 05, 2026

Python Generators — The Empty Log Report Bug

A log pipeline that ran fine for weeks suddenly outputs zero results — that's generator exhaustion.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Production
production tested
July 18, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 25 min
  • Solid grasp of fundamentals
  • Comfortable reading code examples
  • Basic production concepts
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Generator functions use yield to pause and resume execution, freezing local state
  • Calling a generator function returns an object — the body runs only when next() or for loop starts
  • Memory stays O(1): only one value exists at a time, regardless of dataset size
  • Performance cost: ~40ns overhead per yield call vs direct iteration; negligible for I/O-bound pipelines
  • Production trap: exhaust a generator once and it's gone forever — silent empty iterations follow
  • Biggest mistake: assuming the function runs at call time; side effects never fire until iteration
✦ Definition~90s read
What is Generators in Python?

Python generators are functions that use yield instead of return, allowing them to pause execution and resume later while preserving their entire local state. This isn't syntactic sugar — it's a fundamentally different execution model. When a generator function is called, it returns a generator object (an iterator) without executing any code.

Imagine a vending machine that makes each snack on demand the moment you press a button — instead of baking every snack upfront and stuffing them all into a huge bag you have to carry.

Each call to next() runs the function until the next yield, freezes everything, and yields the value. This solves the core problem of processing sequences that don't fit in memory: instead of building a list of 10 million log lines (which costs ~800MB for typical syslog entries), a generator yields them one at a time, consuming only the memory of a single line plus the generator's frame (~a few hundred bytes).

Generators are the backbone of Python's iteration protocol — range(), open() file objects, and map()/filter() all return generators or generator-like iterators. They're the right tool when you need lazy evaluation: processing streams, infinite sequences, or any data where you'd rather compute on demand than precompute.

Don't use them when you need random access, multiple passes over the data, or when the overhead of function calls per item outweighs memory savings (e.g., iterating over a list of 100 integers). The yield from syntax extends this by delegating to sub-generators, which is critical for flattening nested structures like recursive tree traversals or chaining multiple data sources without manual iteration.

Advanced usage includes .send() for two-way communication with a running generator (used in coroutine patterns and async frameworks like asyncio's event loop), and .throw()/.close() for exception handling and cleanup. The classic real-world pattern is processing multi-gigabyte log files: a generator reads lines, another filters by severity, a third parses timestamps — each yielding one item at a time, forming a pipeline that never materializes the full dataset.

This is why tools like itertools (chain, groupby, product) and streaming CSV parsers all rely on generators: they make memory-efficient, composable data processing possible without sacrificing readability.

Plain-English First

Imagine a vending machine that makes each snack on demand the moment you press a button — instead of baking every snack upfront and stuffing them all into a huge bag you have to carry. A Python generator is that vending machine. It produces values one at a time, only when you ask for the next one, and it remembers exactly where it left off each time. You get the same snacks, but without the heavy bag.

Every Python developer hits a wall: they write a reasonable script that loads a dataset, processes it, and crashes — not because the logic is wrong, but because they tried to hold a million rows in memory all at once. It's one of the most common avoidable performance problems, and generators exist to solve it. They're not niche; they power Python's own range(), map(), and zip().

The core problem is the cost of 'eagerness'. A regular list computes and stores every value immediately. Fine for 100 items. A disaster for 10 million log entries, infinite sequences, or streaming API responses where you don't even know the final count. Generators flip the model: they're lazy, producing each value only when the caller asks for the next one. Memory stays flat no matter how large the dataset.

By the end you'll understand why yield exists and how it differs from return, you'll write generator functions and generator expressions with confidence, and you'll know the real-world patterns — log file processing, data pipelines, infinite sequences — where generators genuinely shine. You'll also avoid the two traps that catch almost every developer the first time.

What yield Actually Does — and Why It's Not Just a Fancy return

The single most important thing to understand about generators is what happens to the function's execution state when it hits yield. With a normal return, the function runs, hands back a value, and is completely torn down — local variables gone, position in code gone, everything erased. When a function hits yield, Python does something different: it pauses the function, hands the yielded value to the caller, and freezes the entire execution frame in place — local variables, loop counters, everything. The next time the caller asks for a value by calling next(), Python thaws that frozen frame and continues from the exact line after yield.

This is why a generator function doesn't execute at all when you call it. Calling a generator function just returns a generator object. The body doesn't run until you start consuming that object with next() or a for loop. That single distinction trips up almost every developer the first time.

io/thecodeforge/generators/basic_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import sys

# io.thecodeforge — Basic Generator Implementation
def count_up_to(maximum):
    current = 1
    while current <= maximum:
        # State is frozen right here
        yield current
        # Resumes here on the next call
        current += 1

# 1. Calling the function returns the object, does NOT execute the body
counter = count_up_to(5)
print(f"Object type: {type(counter)}")

# 2. Manual consumption
print(f"First value: {next(counter)}")

# 3. Iteration (handles StopIteration automatically)
for number in counter:
    print(f"Iterated: {number}")

# 4. Memory comparison
eager_list = [i for i in range(10000)]
lazy_gen = (i for i in range(10000))

print(f"List Size: {sys.getsizeof(eager_list)} bytes")
print(f"Gen Size:  {sys.getsizeof(lazy_gen)} bytes")
Output
Object type: <class 'generator'>
First value: 1
Iterated: 2
Iterated: 3
Iterated: 4
Iterated: 5
List Size: 85176 bytes
Gen Size: 112 bytes
⚠ Watch Out:
Calling a generator function returns a generator object instantly — zero code in the function body runs at that point. If you forget this and expect side effects to happen on call, you'll get silent bugs. The body only runs when you start iterating.
📊 Production Insight
In production, we've seen whole monitoring pipelines stay dark because the generator was never iterated — the developer expected side effects on function call.
If you need both setup and lazy iteration, use a class with __iter__ and __next__, not a generator function.
🎯 Key Takeaway
yield pauses and freezes the entire frame.
Calling the generator function does NOT run it.
Never expect side effects from the call — only from iteration.
generators-python Generator Memory Architecture Layered view of generator internals and memory usage Caller Layer for loop | next() calls | .send() Generator Object Frame state | Local variables | Instruction pointer Yield Mechanism Value emission | Pause/resume | Exception propagation Memory Management Lazy evaluation | No full list | Single item at a time Data Source Large log file | Infinite stream | Database cursor THECODEFORGE.IO
thecodeforge.io
Generators Python

Real-World Pattern — Processing Large Log Files

Log files are the textbook generator use case because they're naturally sequential and can grow into the gigabytes. Loading a 10 GB file into a list will crash most systems, but a generator pipeline handles it with a constant memory footprint. The pattern involves 'pipelining' where each step is a generator that pulls from the previous one, ensuring only one line of data exists in RAM at any given time.

By decoupling the reading, filtering, and parsing logic into separate generator functions, you create a modular, production-grade ETL (Extract, Transform, Load) system that is as readable as it is efficient.

io/thecodeforge/pipelines/log_processor.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import os

def get_log_lines(filename):
    """Generator to stream lines from a file."""
    with open(filename, 'r') as f:
        for line in f:
            yield line.strip()

def filter_errors(lines):
    """Generator to filter for ERROR status."""
    for line in lines:
        if "ERROR" in line:
            yield line

def parse_details(error_lines):
    """Generator to extract specific error messages."""
    for line in error_lines:
        yield line.split(" : ")[-1]

# Building the Pipeline (No execution yet)
# raw_log = get_log_lines("production_log.txt")
# errors = filter_errors(raw_log)
# final_report = parse_details(errors)

# for msg in final_report:
#     print(f"Critial Alert: {msg}")
Output
# Scalable O(1) memory usage regardless of file size.
💡Pro Tip:
This pipeline pattern scales from 100 lines to 100 million lines with identical memory usage, because at any instant only one line is alive across the entire chain. In production, replace the list with open('server.log') and you can stream a 10 GB log file with under 1 MB of RAM.
📊 Production Insight
Common failure: someone converts a generator pipeline to a list for debugging, then forgets to revert. Suddenly memory goes from 1 MB to 11 GB.
Rule: never materialise in production. If you must debug, use itertools.islice to sample the first few items.
🎯 Key Takeaway
Generator pipelines keep memory at O(1) by design.
One line in flight at a time — regardless of input size.
Don't break the laziness by calling list() in production.

Memory Usage Comparison: List vs Generator

One of the most compelling reasons to use generators is the dramatic difference in memory consumption. A list stores every element in contiguous memory. A generator stores nothing — it computes each element on demand and discards it after yielding. This comparison table crystallizes the practical trade-offs for common Python workloads.

For a dataset of 10 million integers, a list would consume roughly 80 MB (8 bytes per int * 10M + list overhead). A generator requires just 112 bytes — the size of the generator object itself. The speed difference is negligible for iteration (generators add about 40ns per yield), but the memory savings are enormous.

The table below summarizes the key differences for production decision-making:

io/thecodeforge/generators/memory_comparison.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import sys
import tracemalloc

N = 10_000_000

# Tracemalloc to measure peak memory
tracemalloc.start()

# List version
eager = [i for i in range(N)]
current, peak = tracemalloc.get_traced_memory()
print(f"List: Current = {current / 1024**2:.2f} MB, Peak = {peak / 1024**2:.2f} MB")
tracemalloc.stop()

tracemalloc.start()
# Generator version
lazy = (i for i in range(N))
# Simulate consumption without storing
for _ in lazy:
    pass
current, peak = tracemalloc.get_traced_memory()
print(f"Generator: Current = {current / 1024**2:.2f} MB, Peak = {peak / 1024**2:.2f} MB")
tracemalloc.stop()
Output
List: Current = 762.94 MB, Peak = 762.94 MB
Generator: Current = 0.00 MB, Peak = 0.34 MB
🔥Reality Check:
These numbers scale linearly. A list of 100 million integers would need ~7.6 GB — enough to OOM a typical server. The same generator still uses a few hundred KB. This is why generators are the default choice for any large or unbounded data source.
📊 Production Insight
In production monitoring systems, we've seen teams accidentally materialise a generator by calling sorted() or max() on it. These functions consume the entire generator into a list internally. Always check the documentation: if the function returns a list, it materialises. Prefer functions that accept iterators (like heapq.nlargest) or build your own streaming aggregators.
🎯 Key Takeaway
Lists hold everything in memory (O(n)). Generators hold nothing (O(1)).
For large datasets, generators prevent out-of-memory errors.
Materialise only when you must: sorting, indexing, or multiple passes.
generators-python List vs Generator for Log Processing Memory and performance trade-offs List Comprehension Generator Expression Memory Usage Stores all items in memory Yields one item at a time Execution Eager: builds entire list Lazy: computes on demand Iteration Speed Faster for small datasets Slower per item but scalable Reusability Can iterate multiple times Single-use; exhausted after one pass Suitable For Small to medium data Large or infinite data streams THECODEFORGE.IO
thecodeforge.io
Generators Python

Advanced Mechanics: Infinite Streams and .send()

Because generators are lazy, they are the only way to represent infinite sequences. A while True loop inside a generator isn't a bug—it's a feature. Since the function pauses at every yield, it will never hang your CPU; it simply waits for the caller to request the next value. Furthermore, the .send() method allows you to push data into the generator, effectively turning it into a coroutine for two-way communication.

io/thecodeforge/generators/infinite_stream.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def infinite_fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

def tally_tracker():
    """Receives values and yields the current sum."""
    total = 0
    while True:
        val = yield total
        if val is not None:
            total += val

# Infinite usage
fib = infinite_fibonacci()
print([next(fib) for _ in range(5)])

# Two-way usage
stats = tally_tracker()
next(stats) # Prime the generator
print(f"Total after 10: {stats.send(10)}")
print(f"Total after 25: {stats.send(25)}")
Output
[0, 1, 1, 2, 3]
Total after 10: 10
Total after 25: 35
🔥Interview Gold:
Interviewers love asking 'how would you generate an infinite sequence in Python without running out of memory?' The answer is a generator with a while True loop. The key insight is that yield suspends execution, so the infinite loop never actually spins — it only advances one step per next() call.
📊 Production Insight
Using .send() in production is risky — it's easy to forget to prime the generator with next() first, causing a TypeError: can't send non-None value to a just-started generator.
Rule: always call next() once after creating a .send()-based generator, or wrap initialization in a factory.
🎯 Key Takeaway
Infinite sequences are natural with generators.
.send() enables coroutine-style two-way communication.
Always prime a send-based generator with an initial next() call.

yield from — Generator Delegation Made Simple

When you have nested generators, you could write a for loop to yield all items from a sub-generator. But yield from does it cleaner and faster. It delegates to another generator (or any iterable) and yields each item as if it came from the outer generator. It also propagates StopIteration and handles .send() and .throw() correctly — something a for loop doesn't do.

Use yield from when you need to flatten nested data, compose generators, or build recursive generators. It's the unsung hero of lazy pipeline designs.

io/thecodeforge/generators/yield_from_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def sub_gen():
    yield "A"
    yield "B"

def main_gen():
    yield "Start"
    yield from sub_gen()  # delegates to sub_gen
    yield "End"

for item in main_gen():
    print(item)

# Recursive example: flatten nested lists lazily
def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)  # recurse through sublists
        else:
            yield item

nested_list = [1, [2, [3, 4], 5], 6]
flat = flatten(nested_list)
print(list(flat))  # [1, 2, 3, 4, 5, 6]
Output
Start
A
B
End
[1, 2, 3, 4, 5, 6]
💡Clean vs Loopy:
Compare: 'for x in sub: yield x' vs 'yield from sub'. They produce the same values, but yield from is faster, handles send/throw correctly, and keeps your code clean. Use it whenever you're yielding all items from another generator.
📊 Production Insight
A performance trap: using yield from inside a tight loop that calls it repeatedly (e.g., for each row in a CSV). Each delegation has overhead of ~100ns. For billions of rows, that adds up.
Rule: use yield from for readability; if profiling shows it's a bottleneck, inline the sub-generator logic.
🎯 Key Takeaway
yield from delegates to another generator cleanly.
It propagates send/throw — for loops don't.
Use it for readability, but watch for micro-overhead in hot loops.

yield from for Recursive and Nested Generators

While the basic yield from works for simple delegation, its real power emerges when you need to recursively traverse deeply nested structures. Consider a file system tree, a JSON object with arbitrary nesting, or a game tree. A generator that recursively yields from sub-generators lets you produce a flat stream of elements without building intermediate lists.

The recursive pattern works because each call to yield from flatten(...) creates a new generator that yields items one by one. Python's call stack pushes frames for each level of nesting, but only one value exists at a time. This is a textbook example of lazy recursion: you can flatten a tree of any depth without running out of memory (though you can hit recursion depth limits for very deep trees).

For production use, combine this with itertools.islice or itertools.takewhile to limit output when you only need a subset of the nested data.

io/thecodeforge/generators/recursive_yield_from.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import os

# Recursively walk a directory tree, yielding file paths lazily
def walk_files(root):
    for entry in os.scandir(root):
        if entry.is_dir():
            yield from walk_files(entry.path)
        else:
            yield entry.path

# Usage: only one file path in memory at a time
# for file_path in walk_files("/var/log"):
#     process(file_path)

# Another pattern: nested JSON traversal
def extract_strings(data):
    if isinstance(data, dict):
        for _, v in data.items():
            yield from extract_strings(v)
    elif isinstance(data, list):
        for item in data:
            yield from extract_strings(item)
    elif isinstance(data, str):
        yield data

nested = {
    "a": "hello",
    "b": {"c": ["world", "foo"]},
    "d": [1, {"e": "bar"}]
}
print(list(extract_strings(nested)))
# ['hello', 'world', 'foo', 'bar']
Output
['hello', 'world', 'foo', 'bar']
⚠ Recursion Depth Warning:
Python’s default recursion limit is 1000. If your nested structure exceeds that, use an iterative approach with an explicit stack instead. For filesystem traversal, os.scandir limits depth implicitly, but deep directories can cause RecursionError.
📊 Production Insight
In production log aggregation, we've used recursive yield from to flatten nested JSON log entries from multiple sources. The key advantage: we can stream millions of events without buffering. A common mistake is to call list() on the generator to get all values, which defeats the purpose. Always consume lazily with a for loop.
🎯 Key Takeaway
Recursive yield from flattens nested structures lazily.
Memory stays O(1) — one element at a time.
Watch recursion depth; for extreme depth, use iterative stack.

Generators vs Lists vs Iterators — Knowing When to Use Each

The honest answer to 'when should I use a generator?' is: whenever you don't need all the values at once, or whenever you might not need all of them at all. If you need to sort, reverse, index by position, or pass the same sequence to multiple consumers, use a list — you need all values materialised. If you're transforming or filtering a sequence and consuming it exactly once from start to finish, a generator is almost always the better choice.

One critical difference that surprises people: generators are single-use. Once exhausted, they're done — calling iter() on them again doesn't restart them. A list can be iterated as many times as you like. This is the most common source of subtle bugs with generators in production code.

Custom iterator classes (with __iter__ and __next__) give you the same lazy behaviour as generators but with more control — you can maintain state, support multiple independent iterations, or define a length. Generators are the shortcut for the 80% case where you just need simple, one-shot lazy iteration.

io/thecodeforge/generators/exhaustion_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_gen():
    yield 1
    yield 2

my_gen = get_gen()

# Pass 1
print(list(my_gen)) # [1, 2]

# Pass 2
print(list(my_gen)) # [] - The generator is empty!

# Pass 3: Re-calling the function creates a FRESH generator
fresh_gen = get_gen()
print(list(fresh_gen)) # [1, 2]
Output
[1, 2]
[]
[1, 2]
⚠ Watch Out:
Passing a generator to a function that secretly iterates it fully (like sorted(), max(), list(), or any()) exhausts it silently. Any subsequent attempt to iterate that generator produces nothing. If you need to reuse values, call list() on the generator once and store the result.
📊 Production Insight
In production log processing, we've seen a generator passed to both an error filter and a summary counter. The filter exhausted it, so the counter returned zero. No error — just wrong metrics for hours.
Rule: if you need two pipelines, materialise once with list() and then operate on the list. Or redesign to merge the two consumers into one pass.
🎯 Key Takeaway
Generators are single-use — iterate them once.
Need multiple passes? Call list() once, then use the list.
Custom iterators give you reusable lazy objects — use them for complex state.

Advanced Generator Methods: .send() and .throw()

Beyond simple iteration, generators support two advanced methods that turn them into two-way communication channels: .send() and .throw(). These are often overlooked but essential for building coroutine-like patterns, cooperative multitasking, and generator-based pipelines with error handling.

.send(val) resumes the generator and passes a value into it, which becomes the result of the yield expression inside the generator. This lets you inject data from outside. .throw(type, value, traceback) raises an exception at the point where the generator was paused. The generator can catch it (via try/except around the yield) and yield another value, or let it propagate to terminate the generator.

A common use case for .throw() is to signal a generator to clean up or stop early, akin to a cancel signal. For pipelines, you can throw an exception into the middle of a chain to abort processing without manually draining the generator.

io/thecodeforge/generators/send_throw.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# .send() example
from typing import Generator

def accumulator() -> Generator[float, float, None]:
    total = 0.0
    while True:
        increment = yield total
        if increment is not None:
            total += increment

acc = accumulator()
next(acc)  # prime
print(acc.send(10.5))  # 10.5
print(acc.send(3.2))   # 13.7


# .throw() example – stopping a generator gracefully
def produce_items():
    try:
        for i in range(1000):
            yield i
    except GeneratorExit:
        print("Cleanup: closing generator")
    except Exception as e:
        print(f"Caught exception: {e}")

producer = produce_items()
print(next(producer))  # 0
print(next(producer))  # 1
producer.throw(RuntimeError, "cancel")
# output: Caught exception: cancel
# generator ends

# Without catching, .throw() propagates
def simple_gen():
    yield 1
    yield 2

g = simple_gen()
next(g)
try:
    g.throw(ValueError, "test")
except ValueError:
    print("ValueError propagated")
Output
10.5
13.7
0
1
Caught exception: cancel
ValueError propagated
💡Priming Gotcha:
You must call next() on a generator before using .send() or .throw(). The first call sets up the generator at its first yield point. Forgetting this raises TypeError: can't send non-None value to a just-started generator.
📊 Production Insight
In production, .throw() is useful for aborting a long-running generator pipeline (e.g., when a timeout occurs). However, catching GeneratorExit in the generator is delicate — if you catch it and yield again, Python will raise RuntimeError. Use .send() for data injection only when you need push-based patterns; otherwise, prefer passing data through function arguments to keep pipelines predictable.
🎯 Key Takeaway
.send() injects values into generators.
.throw() injects exceptions.
Always prime with next() before using these methods.
Use .throw() sparingly — it can make control flow confusing.

Generator Expressions: The One-Liner That Saves Your Stack

You've used list comprehensions. They're clean, readable, and will crash your box on a 10GB dataset. Generator expressions do the same thing without materializing the entire list. The syntax is almost identical — swap square brackets for parentheses.

But here's the catch: generator expressions are single-pass. You can't index them, you can't slice them, and once you've consumed them they're gone. This isn't a bug — it's the whole point. You trade random access for memory efficiency that scales to any dataset size.

The real power comes from chaining them. A pipeline of generator expressions processes data in a single pass without intermediate storage. Three comprehension-like transformations? That's three generator expressions linked together, streaming elements one at a time. No intermediate lists, no memory spikes, no surprise OOM kills in production.

GeneratorExpressionPipeline.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — python tutorial

def read_sensor_log(path):
    with open(path) as f:
        for line in f:
            yield line.strip()

raw = read_sensor_log("/var/log/sensors/temperature.csv")

# Three transformations, zero intermediate lists
readings = (line.split(",") for line in raw)
validated = (r for r in readings if len(r) == 3)
temperatures = (float(r[1]) for r in validated if r[2] == "OK")

# This single loop streams through all three stages
for temp in temperatures:
    if temp > 85.0:
        print(f"ALERT: temperature {temp} exceeds threshold")
Output
ALERT: temperature 91.2 exceeds threshold
ALERT: temperature 88.7 exceeds threshold
⚠ Production Trap: Single-Pass Blindness
Generator expressions consume their source exactly once. If you iterate over the same generator twice, the second loop yields nothing. Always name your generator or materialize it with list() if you need multiple passes. Your logs won't debug this for you at 3 AM.
🎯 Key Takeaway
Generator expressions replace list comprehensions when memory is the constraint. Parentheses not brackets — and remember: one pass only.

Profiling Generator Performance — When Lazy Isn't Faster

Developers assume generators are always faster because they're memory-efficient. That's wrong. Generators have overhead: function call state tracking, yield/resume cycles, and the context switch between iterations. For small datasets, a list comprehension beats a generator expression every time. The question is where the crossover point lives.

List comprehension: allocate a list, compute all values, return. If you only need 5 items from a 10,000-element collection, that's 9,995 wasted computations. Generator: compute one value, yield, pause. If you break early, you skip the rest. Zero waste.

But if you iterate every single element and the computation per element is trivial — say a simple integer operation — the list's lower per-element overhead wins. The generator's yield machinery adds microseconds per iteration. On a million elements, microseconds add up to seconds.

The rule: benchmark with your actual data shape. Profile before you optimize. And never replace a list comprehension with a generator expression just because someone on Reddit said it's "better." It's only better when you're memory-bound or you won't consume the entire sequence.

ProfileGenerators.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge — python tutorial

import time
import sys

def generate_ids(n):
    for i in range(n):
        yield i * 2

def list_ids(n):
    return [i * 2 for i in range(n)]

n = 10_000_000

# Generator — lazy, memory-light
start = time.perf_counter()
gen = generate_ids(n)
for val in gen:
    _ = val  # simulate processing each item
print(f"Generator: {time.perf_counter() - start:.2f}s")

# List — eager, memory-heavy
start = time.perf_counter()
lst = list_ids(n)
for val in lst:
    _ = val
print(f"List:       {time.perf_counter() - start:.2f}s")
Output
Generator: 1.21s
List: 0.89s
💡Senior Shortcut: Profile in Production Context
Run benchmarks with data sizes that match your production workload, not your laptop. A generator that's 2x slower on a dev box might be 10x faster when memory pressure kicks in at 80% RAM utilization. Always measure end-to-end throughput, not just wall clock time.
🎯 Key Takeaway
Generators optimize memory, not raw speed. Use them when you're constrained by memory or when you process partial sequences. Profile first; optimize second.

send() Is How You Talk Back to a Generator

Most devs treat generators as one-way data pipes. You call next(), you get a value. That's fine for iterating over log files. But generators can receive data mid-execution using .send(). This turns them into coroutines — lightweight cooperative threads.

The trick: .send() resumes the generator AND injects a value into the yield expression. The first call MUST be next() or send(None) because no yield has been hit yet. After that, each send(val) sets yield's return value. This is how you implement state machines, streaming pipelines, or cooperative task schedulers without threading overhead.

Why bother? Because you avoid global state, external queues, and callback hell. The generator keeps its own context on the stack. Send data in, get data out. Clean, testable, production-hardened.

send_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — python tutorial

def running_average():
    total = 0.0
    count = 0
    average = None
    while True:
        new_value = yield average
        if new_value is None:
            continue
        total += new_value
        count += 1
        average = total / count

avg_gen = running_average()
next(avg_gen)                # prime it
print(avg_gen.send(10))      # 10.0
print(avg_gen.send(20))      # 15.0
print(avg_gen.send(30))      # 20.0
Output
10.0
15.0
20.0
⚠ Production Trap:
Forget to prime with next() and send() raises TypeError. Wrap generator creation in a function that returns the primed generator — or you'll debug this at 2 AM.
🎯 Key Takeaway
send() converts a generator into a two-way channel. Use it for stateful data pipelines, not file iteration.

close() Is How You Fire a Generator Cleanly

Generators hold resources: open file handles, database cursors, socket connections. If you stop iterating early — break out of a for loop, raise an exception — the generator's stack frame freezes. That file handle stays open until garbage collection kicks in. That's a leak waiting to happen in production.

.close() raises GeneratorExit inside the generator at its current yield point. If your generator has a try/finally block, that finally runs. No other exception is raised to the caller. It's a clean, deterministic shutdown.

Pair this with contextlib.closing() or wrap your generator in a context manager. Never rely on gc to clean up your I/O. Explicit shutdown beats implicit leaks every time. Treat .close() like closing a file handle — you don't walk away leaving files open, don't walk away leaving generators open.

close_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — python tutorial

def read_lines(file_path):
    try:
        f = open(file_path, 'r')
        for line in f:
            yield line.strip()
    finally:
        f.close()
        print('File closed')

gen = read_lines('/etc/hostname')
print(next(gen))
gen.close()
print('Generator closed, file released')
Output
my-machine
File closed
Generator closed, file released
💡Senior Shortcut:
Wrap untrusted generator usage in contextlib.closing(gen) to ensure .close() is called even if the caller crashes. One import, zero excuses.
🎯 Key Takeaway
.close() triggers finally blocks inside generators. Use it to release file handles, network sockets, and database connections — never let gc do your cleanup.

Generator Expressions: Lazy Evaluation Pattern

Generator expressions are a memory-efficient way to create iterators using a syntax similar to list comprehensions but with parentheses instead of square brackets. They produce items one at a time and only when requested, making them ideal for processing large datasets without loading everything into memory.

Consider reading a large file line by line and filtering for specific patterns. A list comprehension would read the entire file into memory, while a generator expression processes each line lazily:

```python # List comprehension (eager) lines = [line.strip() for line in open('huge_log.txt') if 'ERROR' in line]

# Generator expression (lazy) lines_gen = (line.strip() for line in open('huge_log.txt') if 'ERROR' in line) ```

The generator expression does not create a list; it returns an iterator that yields filtered lines on demand. This is especially useful when chaining multiple transformations, as each step remains lazy:

``python # Chaining generator expressions lines = (line.strip() for line in open('huge_log.txt')) errors = (line for line in lines if 'ERROR' in line) count = sum(1 for _ in errors) # only one line in memory at a time ``

Generator expressions also support conditional logic and can be passed directly to functions that accept iterables, like sum(), min(), or max(). This pattern reduces memory overhead and improves performance for large or infinite streams.

However, be cautious: generator expressions are single-use. Once exhausted, they cannot be reused. If you need to iterate multiple times, convert to a list or recreate the generator.

generator_expressions.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
# Generator expression for lazy filtering
lines_gen = (line.strip() for line in open('huge_log.txt') if 'ERROR' in line)

# Chaining generator expressions
lines = (line.strip() for line in open('huge_log.txt'))
errors = (line for line in lines if 'ERROR' in line)
count = sum(1 for _ in errors)  # memory efficient

# Generator expression with function call
squares = (x*x for x in range(10))
print(list(squares))  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
💡Use generator expressions for one-pass processing
📊 Production Insight
In production, use generator expressions for streaming data (e.g., log processing, API pagination) to avoid memory bloat. Combine with itertools for advanced lazy operations.
🎯 Key Takeaway
Generator expressions provide lazy evaluation with minimal syntax, ideal for memory-efficient data processing pipelines.

yield from: Delegating to Subgenerators

The yield from expression allows a generator to delegate part of its operation to another generator, simplifying code when dealing with nested or recursive data structures. It yields all values from the subgenerator, effectively flattening the iteration.

Without yield from, you would need a loop to manually yield each item from the subgenerator:

``python def flatten(nested): for sublist in nested: for item in sublist: yield item ``

``python def flatten(nested): for sublist in nested: yield from sublist ``

yield from also handles bidirectional communication: values sent via .send() to the delegating generator are forwarded to the subgenerator, and exceptions sent via .throw() are propagated. This makes it essential for building complex coroutines.

``python def traverse(tree): if isinstance(tree, dict): for key, value in tree.items(): yield key yield from traverse(value) elif isinstance(tree, list): for item in tree: yield from traverse(item) else: yield tree ``

Here, yield from recursively yields all elements from nested dictionaries and lists without manual iteration.

When the subgenerator returns a value, yield from captures it as the result of the expression. This is useful for coroutine-style patterns where the subgenerator computes a result.

yield_from_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Flatten nested lists with yield from
def flatten(nested):
    for sublist in nested:
        yield from sublist

nested = [[1, 2], [3, 4], [5]]
print(list(flatten(nested)))  # [1, 2, 3, 4, 5]

# Recursive tree traversal
def traverse(tree):
    if isinstance(tree, dict):
        for key, value in tree.items():
            yield key
            yield from traverse(value)
    elif isinstance(tree, list):
        for item in tree:
            yield from traverse(item)
    else:
        yield tree

tree = {'a': [1, 2], 'b': {'c': 3}}
print(list(traverse(tree)))  # ['a', 1, 2, 'b', 'c', 3]
🔥yield from simplifies generator delegation
📊 Production Insight
In production, use yield from for recursive data processing (e.g., JSON flattening, directory traversal) and for composing coroutines in legacy async code.
🎯 Key Takeaway
yield from enables clean delegation to subgenerators, handling iteration and bidirectional communication seamlessly.

Generator-Based Coroutines vs async def

Before Python 3.5 introduced async def and await, coroutines were implemented using generators with yield and yield from. These generator-based coroutines used @asyncio.coroutine decorator and yield from to await futures. While still functional, they are now legacy and discouraged in favor of native coroutines.

```python import asyncio

@asyncio.coroutine def fetch_data(url): response = yield from aiohttp.request('GET', url) data = yield from response.json() return data ```

``python async def fetch_data(url): response = await aiohttp.request('GET', url) data = await response.json() return data ``

Key differences
  • Native coroutines are defined with async def and use await instead of yield from.
  • Generator-based coroutines return a generator object; native coroutines return a coroutine object.
  • Native coroutines are not iterable and cannot be used in for loops or with next().
  • Generator-based coroutines support .send() and .throw(), which are not needed in native coroutines.
  • Performance: native coroutines are faster and have less overhead.
When to use which
  • For new async code, always use async def and await. They are cleaner, faster, and the standard.
  • Generator-based coroutines are only relevant for maintaining legacy code or for educational purposes.
  • Generators themselves remain useful for lazy iteration, not for async control flow.

Migration path: Replace @asyncio.coroutine with async def, and replace yield from with await. Ensure all awaited objects are awaitable (e.g., futures, tasks, coroutines).

coroutine_comparison.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Generator-based coroutine (legacy)
import asyncio

@asyncio.coroutine
def legacy_fetch(url):
    response = yield from aiohttp.request('GET', url)
    data = yield from response.json()
    return data

# Native coroutine (modern)
async def modern_fetch(url):
    response = await aiohttp.request('GET', url)
    data = await response.json()
    return data

# Usage is similar
loop = asyncio.get_event_loop()
result = loop.run_until_complete(modern_fetch('http://example.com'))
⚠ Avoid generator-based coroutines in new code
📊 Production Insight
In production, ensure all async code uses native coroutines. For legacy codebases, plan migration to async/await to benefit from better performance and future compatibility.
🎯 Key Takeaway
Native coroutines with async/await have replaced generator-based coroutines; use them for all new asynchronous Python code.
● Production incidentPOST-MORTEMseverity: high

The Silent Empty Log Report — Generator Exhaustion in Production

Symptom
A log processing system that had been running fine for weeks suddenly output zero results. Log lines were being read, but the final alert report was empty.
Assumption
The developer assumed the generator object could be iterated multiple times, just like a list. They passed the same generator to two consumers: one filtered errors, the other counted total lines.
Root cause
The generator was passed to filter_errors() which iterated it fully. When the count function later tried to iterate the same generator, it received nothing — StopIteration was already raised. No error was thrown; the for loop just didn't execute.
Fix
Change the pipeline to either materialize the data once with list(raw_lines) if both passes are needed, or restructure to avoid double consumption. In this case, counting should happen side-effect-free in the same pass as filtering.
Key lesson
  • Generators are single-use. Passing one to a function that iterates it fully exhausts it silently.
  • If multiple consumers need the same data, call list() on the generator once and store the result.
  • Never assume iteration order or count — verify with a small test before deploying any generator pipeline.
Production debug guideSymptom → Action. Diagnose silent failures fast.4 entries
Symptom · 01
Generator pipeline returns no results even though input data exists
Fix
Check if the generator was already consumed earlier. Wrap in list() at the pipeline start and compare output. Add a debug print('Consumed by', func.__name__) in each consumer function.
Symptom · 02
Memory spikes when processing large files
Fix
Look for an accidental list() call inside the pipeline. For example, list(lines) in a filter function materialises everything. Replace with lazy chaining.
Symptom · 03
Generator function side effects (like writing to a file) never happen
Fix
Verify you actually iterate the generator. print(type(obj)) — if it's a generator, you forgot to call next(). Use for value in generator: or list(generator) to trigger execution.
Symptom · 04
Generator runs forever (hangs) in a for loop
Fix
Your generator has an infinite loop with no terminating condition. Check that the while True loop has a break condition or that the caller limits iterations. Use itertools.islice(generator, n) to cap consumption.
★ Quick Debug Cheat Sheet for GeneratorsStop wasting time on generator quirks. These are the three most common symptoms and exactly what to do.
Generator returns empty when you expect values
Immediate action
Check if the generator was already consumed by another consumer.
Commands
print(type(my_gen)) — confirm it's a generator object, not a function.
print(list(my_gen)) — if empty, it's exhausted. Recreate by calling the generator function again.
Fix now
Store a list if multiple passes needed: data = list(my_gen_func()) and then work with data.
Pipeline using generators is slower than expected+
Immediate action
Check if you're accidentally materialising intermediate results.
Commands
Add print(len(list(intermediate))) at each stage to see memory usage.
Profile with timeit.timeit(''.join(chunk) for chunk in chunks) vs [chunk for chunk in chunks].
Fix now
Ensure every function in the chain returns a generator (uses yield), not a list.
Generator body code runs at import time?+
Immediate action
No, it doesn't. That's the bug: you called the function expecting it to execute.
Commands
print('called') inside the generator — does it print? Only if you iterate.
Check for accidental () call: my_gen_func() returns generator, my_gen_func does nothing.
Fix now
Wrap the generator call in list() or a for loop to trigger body execution.
Generator vs List vs Custom Iterator
Feature / AspectGenerator (yield)ListCustom Iterator Class
Memory usageO(1) — constant, holds 1 value at a timeO(n) — holds all n values simultaneouslyO(1) — same as generator
Speed to first valueInstant — starts on first next() callSlower — must compute all values before you get anyInstant — same as generator
Reusable (multi-pass)No — exhausted after one full iterationYes — iterate as many times as neededYes — if __iter__ returns a new iterator each time
Supports indexing (list[2])No — forward-only, no random accessYes — full index and slice supportNo — forward-only unless you implement __getitem__
Works with infinite sequencesYes — naturally handles unbounded outputNo — would require infinite memoryYes — same as generator
Complexity to createMinimal — just add yield to a functionMinimal — [expr for x in iterable]Moderate — define class with __iter__ and __next__
Best forLarge files, streams, pipelines, one-shot transformsSmall-medium data needing sort, index, or reuseWhen you need reusable lazy behavior with additional methods
⚙ Quick Reference
15 commands from this guide
FileCommand / CodePurpose
iothecodeforgegeneratorsbasic_demo.pydef count_up_to(maximum):What yield Actually Does
iothecodeforgepipelineslog_processor.pydef get_log_lines(filename):Real-World Pattern
iothecodeforgegeneratorsmemory_comparison.pyN = 10_000_000Memory Usage Comparison
iothecodeforgegeneratorsinfinite_stream.pydef infinite_fibonacci():Advanced Mechanics
iothecodeforgegeneratorsyield_from_demo.pydef sub_gen():yield from
iothecodeforgegeneratorsrecursive_yield_from.pydef walk_files(root):yield from for Recursive and Nested Generators
iothecodeforgegeneratorsexhaustion_demo.pydef get_gen():Generators vs Lists vs Iterators
iothecodeforgegeneratorssend_throw.pyfrom typing import GeneratorAdvanced Generator Methods
GeneratorExpressionPipeline.pydef read_sensor_log(path):Generator Expressions
ProfileGenerators.pydef generate_ids(n):Profiling Generator Performance
send_example.pydef running_average():send() Is How You Talk Back to a Generator
close_example.pydef read_lines(file_path):close() Is How You Fire a Generator Cleanly
generator_expressions.pylines_gen = (line.strip() for line in open('huge_log.txt') if 'ERROR' in line)Generator Expressions
yield_from_example.pydef flatten(nested):yield from
coroutine_comparison.py@asyncio.coroutineGenerator-Based Coroutines vs async def

Key takeaways

1
yield pauses a function and freezes its entire execution frame
local variables, loop state, everything — until the next next() call resumes it from exactly where it stopped.
2
Generators are single-use
once the last value has been yielded, the generator object is permanently exhausted. Iterating it again produces nothing and raises no error — a silent bug if you're not aware.
3
The lazy pipeline pattern
chaining generator functions so each pulls from the previous on demand — keeps memory usage flat at O(1) regardless of dataset size, making it the go-to architecture for log processing, ETL, and data streaming.
4
Use a generator when you consume a sequence once from start to finish; use a list when you need sorting, indexing, random access, or multiple passes over the same data.
5
yield from delegates to another generator and propagates send/throw correctly
use it for clean composability.
6
Always prime a .send()-based generator with an initial next() call to avoid TypeError.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a generator function and a regular functi...
Q02SENIOR
How would you use a generator to process a 50 GB CSV file on a machine w...
Q03SENIOR
If I convert a generator expression to a list comprehension, the results...
Q04SENIOR
Explain the internal mechanism of 'StopIteration' and how Python's for-l...
Q05SENIOR
What is 'Generator Delegation' and how do you use 'yield from' to flatte...
Q06SENIOR
Can you return a value in a generator? If so, what happens to that value...
Q01 of 06JUNIOR

What is the difference between a generator function and a regular function, and what happens to the execution frame when yield is encountered?

ANSWER
A regular function uses return and discards its local state after execution. A generator function uses yield. When yield is hit, the function pauses, the yielded value is sent to the caller, and the entire execution frame (local variables, loop counters, instruction pointer) is frozen. The next time next() is called, execution resumes from right after the yield. The generator function returns a generator object when called, not a value.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between yield and return in Python?
02
Can a Python generator function use both yield and return?
03
Are Python generator expressions the same as list comprehensions?
04
What is 'yield from' and when should I use it?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Verified
production tested
July 18, 2026
last updated
2,466
articles · all by Naren
🔥

That's Functions. Mark it forged?

9 min read · try the examples if you haven't

Previous
Decorators in Python
5 / 11 · Functions
Next
Closures in Python