Back to Blog
Python

Python Generator Lazy Evaluation Explained

python generator lazy evaluation: Understand how Python generators implement lazy evaluation, when they save memory, and how to build efficient data pipelines.

generatorslazy evaluationmemory efficiencyyielditerators
Diagram showing a generator function yielding values one at a time while the rest of the sequence remains dormant, illustrating lazy evaluation.

Python generator lazy evaluation is the mechanism that lets a function produce values on demand instead of computing and storing them all at once. This behavior is central to writing memory-efficient code when dealing with large or infinite data sequences. A generator function, defined with yield, suspends its execution after each value is produced, and resumes only when the next value is requested. This is fundamentally different from a regular function that returns a complete list after all computations finish.

What Lazy Evaluation Means in Python Generators

Lazy evaluation means that an expression is not evaluated until its value is actually needed. In the context of generators, it means that the code inside the generator function runs incrementally. Each call to next() on the generator object executes the function body up to the next yield statement, returns that value, and then freezes the entire local state. This state includes variable bindings, the instruction pointer, and the stack frame.

Contrast this with a list comprehension like [x * 2 for x in range(10)], which computes all ten values immediately and stores them in memory. A generator expression (x * 2 for x in range(10)) produces a generator object that computes each value only when iterated. The difference becomes critical when the data source is large or when the consumer may stop early.

How Generator Functions Work: yield and Suspension

A generator function is any function that contains at least one yield statement. When called, it does not execute the body. Instead, it returns a generator object that implements the iterator protocol. The body runs only when iteration begins.

def count_up_to(n): i = 0 while i < n: yield i i += 1 counter = count_up_to(3) print(next(counter)) # 0 print(next(counter)) # 1 print(next(counter)) # 2 # next(counter) would raise StopIteration

After the first next() call, the function executes until yield i, returns 0, and pauses. The local variable i and the loop position are preserved. The next call resumes immediately after the yield, increments i, and continues the loop. This suspension and resumption is the core of lazy evaluation.

A generator can also receive values back via send(), which allows two-way communication. This is useful for coroutine-style patterns, but the fundamental lazy behavior remains unchanged: the function only advances when explicitly asked.

Memory Behavior: Comparing Generators with Lists

The most immediate benefit of lazy evaluation is memory usage. When you create a list, every element exists in memory simultaneously. For a list of one million integers, that is roughly 28 MB for the integer objects plus the list overhead. A generator that yields those same integers holds only the generator's internal state—a few bytes—plus the current value.

Consider reading a large file line by line:

# List approach: loads every line into memory with open('big_log.txt') as f: lines = f.readlines() for line in lines: process(line) # Generator approach: processes one line at a time with open('big_log.txt') as f: for line in f: # f is already an iterator, but the pattern generalizes process(line)

The second version does not build a list of all lines. It reads the next line only when the loop requests it. If the file is gigabytes in size, the generator approach keeps the memory footprint flat. The same principle applies to any stream of data, not just files.

This does not mean generators are always faster. They add per-item overhead from the suspension and resume mechanism. For small sequences, a list may be both faster and simpler. The tradeoff is between memory and CPU, and the correct choice depends on the size of the data and how much of it you actually consume.

Practical Use Cases for Lazy Evaluation

Generators shine when you need to process data that is too large to fit in memory, when you want to avoid computing values that may never be used, or when you need to represent infinite sequences.

Processing large datasets: Reading a CSV, parsing log files, or streaming API responses are natural fits. The generator yields one record at a time, and the consumer processes it and discards it.

Early termination: If you are searching for the first item that satisfies a condition, a generator avoids computing the rest of the sequence. For example, finding the first prime number above a threshold:

def primes(): yield 2 candidate = 3 while True: if all(candidate % p != 0 for p in range(3, int(candidate**0.5) + 1, 2)): yield candidate candidate += 2 first_above_1000 = next(p for p in primes() if p > 1000)

The generator primes() is infinite, but next() only computes until it finds a value above 1000. The rest of the sequence is never generated.

Infinite sequences: Generators can represent mathematical series, sensor readings, or UI event streams that have no defined end. The consumer decides when to stop.

Combining Generators: Pipelines and Infinite Sequences

Because generators are iterators, they can be chained to form data pipelines. Each generator transforms the stream without materializing intermediate results. This is a powerful pattern for composing data processing steps.

def read_lines(filename): with open(filename) as f: for line in f: yield line.strip() def filter_comments(lines): for line in lines: if not line.startswith('#'): yield line def split_words(lines): for line in lines: yield from line.split() pipeline = split_words(filter_comments(read_lines('config.txt'))) for word in pipeline: print(word)

Each generator in the pipeline pulls from the previous one only when the next value is requested. The entire chain is lazy. This avoids building intermediate lists and keeps the memory footprint proportional to the largest single item, not the total data size.

The yield from syntax delegates to a subgenerator, which simplifies flattening nested iterables. It is equivalent to iterating over the subgenerator and yielding each item, but it also handles send() and exceptions correctly.

Performance Considerations and When Not to Use Generators

Generators are not a universal performance improvement. They reduce memory usage, but they add overhead for each next() call. The interpreter must save and restore the generator's stack frame, which is more expensive than appending to a list. For small collections, a list comprehension is often faster and simpler.

Use generators when:

  • The data set is large enough that memory becomes a constraint.
  • The consumer may stop early, so computing all values would be wasted work.
  • You are building a pipeline that would otherwise require multiple intermediate lists.

Avoid generators when:

  • You need random access to elements by index. Generators only support sequential access.
  • You need to iterate over the data multiple times. A generator is exhausted after one pass; you would need to recreate it or store the results.
  • The sequence is small and the overhead of generator machinery is not justified.

Another operational concern is that generators are single-use. Once exhausted, they cannot be reset. If you need to iterate the same data twice, you must either store it in a list or create a new generator. This is a common source of bugs when code assumes a generator can be reused like a list.

Common Pitfalls with Generator State and Exhaustion

A generator holds its state only while it is alive. If you pass a generator to multiple functions, they all consume the same underlying iterator. This can lead to unexpected behavior if you assume each function gets a fresh sequence.

def process_first(iterable): return next(iterable) def process_rest(iterable): return list(iterable) gen = (x for x in range(5)) print(process_first(gen)) # 0 print(process_rest(gen)) # [1, 2, 3, 4]

The first function consumed the first item, so the second function only sees the remaining four. This is not a bug in the generator; it is the expected behavior of any iterator. To avoid surprises, be explicit about whether a function consumes an iterator or only reads it without side effects.

Another pitfall is using a generator in a with block or a context that closes it prematurely. For example, a generator that reads from a file will close the file when the generator is garbage collected or explicitly closed with .close(). If you try to iterate after the file is closed, you get a ValueError. Always ensure that the generator's lifetime matches the scope where you need its data.

Finally, be aware that yield inside a try/finally block will run the finally clause when the generator is closed. This is useful for cleanup, but it also means that closing a generator early can trigger side effects. If you do not want that, avoid putting cleanup logic in a generator that may be abandoned without full iteration.

python generator lazy evaluation: Practical Usage and Code E | RYUSLOG DEV