Imagine a ticket machine that insists on printing a million tickets before handing you the first one.
Some programs behave like that: they build an entire collection even when the caller only wants its first few useful results. Python generators offer a different agreement: produce the next value when someone asks for it, then pause.
Python generators are iterators that produce values incrementally while preserving the execution state needed to continue. A generator function uses yield to hand a value to its caller and suspend execution. A later request resumes it. [1][2]
Let’s build a source capable of producing one million synthetic network measurements—and ask it for just five interesting ones.
The examples use the standard library and were tested with CPython 3.12.14. They make no network requests, and the latency values are invented.
How Python Generators Pause and Resume
Run this self-contained example:
def tickets():
print("The machine starts")
yield "ticket A"
print("The machine resumes")
yield "ticket B"
machine = tickets()
print("Created")
print(next(machine))
print(next(machine))
Output:
Created
The machine starts
ticket A
The machine resumes
ticket B
Calling tickets() created a generator object. It did not run the function’s body. The first next() advanced execution to the first yield; the second resumed it and reached the next one. Local variables and the execution position survive the pause. [1]
Reaching the end terminates the generator. A for loop handles that exhaustion through the iterator protocol; you normally do not have to manage StopIteration yourself. [3]
yield is therefore a useful pause in a calculation, rather than an instruction to calculate every result immediately.
A Million Possible Measurements, Five Requested Results
Here is a second self-contained program:
from itertools import islice
def latency_samples(count):
for sample_id in range(1, count + 1):
yield sample_id, 5 + (sample_id * 37) % 500
def slow_samples(records, threshold_ms):
for sample_id, rtt_ms in records:
if rtt_ms >= threshold_ms:
yield sample_id, rtt_ms
stream = slow_samples(latency_samples(1_000_000), threshold_ms=500)
first_five = list(islice(stream, 5))
print(first_five)
Output:
[(27, 504), (54, 503), (81, 502), (108, 501), (135, 500)]
Each tuple contains a sample identifier and an invented round-trip time in milliseconds. The arithmetic makes the example repeatable; it is not a model of real Internet latency.
The pipeline has three jobs:
latency_samples()produces measurements when requested.slow_samples()requests measurements until it finds one meeting the threshold.islice(stream, 5)requests at most five matching results. The finallist()stores those five results, not all million source records. [4]
The fifth match is sample 135. This prefix therefore consumes 135 source records, not a million—and not merely five.
That distinction matters. A filter may inspect many inputs to produce one output. Laziness avoids unnecessary future work; it does not make the search for a match free.
Follow the Demand Backward
The consumer asks for a result. The filter asks its source for a record. If the record is unsuitable, the filter asks again. Once a match appears, the value travels back to the consumer and the pipeline pauses.
There is no background worker secretly filling a million-item list. These ordinary generators advance synchronously when their caller requests values.
The input is also economical: range() represents a sequence through its bounds and step instead of storing every integer. It is a reusable sequence, not a generator. [3]
This is why you should examine both ends of a pipeline. Wrapping a generator around an already-created million-row list does not remove that list. Likewise, an API or database driver may have loaded a complete result before your generator sees its first item.
Small Syntax, Different Storage Behavior
A generator expression is a compact way to describe incremental production:
total = sum(n * n for n in range(10))
print(total)
Output:
285
sum() consumes the generated values without first requiring a list of them. The corresponding list comprehension would be [n * n for n in range(10)]. [5][6]
| Construct | What it represents | Can a fresh traversal restart it? |
|---|---|---|
| A list comprehension | A list containing the produced results | Yes; the list remains available. |
| A generator expression | A generator iterator producing results as consumed | The same exhausted generator does not restart. Create another one. |
range() | A compact integer sequence | Yes; it can supply a fresh iterator. |
There is a timing nuance: laziness does not postpone every surrounding operation. Function arguments are evaluated before a call, and a generator expression evaluates its leftmost iterable expression when the generator expression is created. Later computation can therefore fail at a different time from setup. [5]
The Second Loop Can Be Surprisingly Empty
A generator has a current position. Consuming it changes that position:
squares = (n * n for n in range(3))
print(list(squares))
print(list(squares))
Output:
[0, 1, 4]
[]
The second list() received an exhausted iterator. It did not ask Python to repeat the original calculation. Create a new generator when another pass is required. [2][3]
The same rule applies to our preview. islice() has already advanced stream past its first five matching records. Continuing that stream starts from its current position.
For a complete count from the beginning, create a fresh pipeline:
total_slow = sum(1 for _ in slow_samples(latency_samples(1_000_000), 500))
print(total_slow)
Output:
10000
Run that final fragment after the pipeline definitions above. It consumes the entire new source while retaining a count rather than a list of all matches.
A Fairer Memory Experiment
Comparing a fully built list with an unconsumed generator measures different amounts of work. Let’s instead complete the same calculation in both versions: sum the squares from zero through 999,999.
This optional example is self-contained:
import gc
import tracemalloc
def list_total(count):
return sum([n * n for n in range(count)])
def generator_total(count):
return sum(n * n for n in range(count))
def traced_peak(work, count):
gc.collect()
tracemalloc.start()
try:
answer = work(count)
_, peak = tracemalloc.get_traced_memory()
return answer, peak
finally:
tracemalloc.stop()
for label, work in [("List", list_total), ("Generator", generator_total)]:
answer, peak = traced_peak(work, 1_000_000)
print(label, answer, peak)
One run of the companion program on CPython 3.12.14 produced the following peak-allocation measurements:
| Complete calculation | Peak traced Python allocations |
|---|---|
| List version | 40,448,256 bytes, about 38.6 MiB |
| Generator version | 464 bytes |
Both returned 333332833333500000. The generator version still calculated every square. It avoided retaining all those squares together.
Those figures describe this run’s allocations traced during the calculation, not total process memory. The interpreter, previously allocated objects, and some native allocations are outside that measurement. Different environments can produce different numbers. tracemalloc documents precisely this allocation-tracing scope. [7]
The result demonstrates a storage difference; it is not a timing benchmark or a claim that an entire Python process fits into 464 bytes.
Lazy Does Not Mean Free, Parallel, or Automatically Safe
A generator can retain large objects in its local state. A consumer can also accumulate every yielded result. Calling list(stream) or sorted(stream) materializes output, so the overall memory behavior depends on the complete program. [2][6]
Yielding an object does not copy it. If a producer repeatedly yields the same mutable list, collecting those references can reveal the same aliasing problem explained in The Haunted Python List.
Resource ownership matters too. A generator suspended inside a file-reading context can keep that file open. Arrange deterministic cleanup when stopping early; contextlib.closing is one option for an owned generator that implements close(). [8]
Finally, ordinary yield does not schedule other tasks while a network operation blocks. Our Python asyncio guide explores cooperative asynchronous execution, which addresses a different problem. [9]
Use a list when retaining, indexing, sorting, or repeatedly traversing a manageable collection is what the task needs. Use a generator when incremental production fits the consumer’s behavior. Neither choice is a universal speed upgrade.
Let the Consumer Decide How Much Happens
The million-record source was a description of possible work. The first consumer needed five matches, so the pipeline stopped after 135 inputs. The counting consumer wanted the full answer, so a fresh pipeline processed all million.
That is the useful idea behind Python generators: production follows consumption, with execution state carried across each pause.
If you are exploring Python through our programming languages overview, this is a small feature with a large design consequence. Instead of deciding up front to keep every result, you can give the caller control over how far the calculation goes.
The ticket machine can hand over the first ticket before printing the rest of the roll.
References and Further Reading
- PEP 255: Simple Generators—generator functions and suspended execution.
- Python Glossary: Generators and Iterators.
- Python Built-in Types: Iterator and Range Types.
- Python itertools: islice.
- Python Language Reference: Generator Expressions.
- Python Built-in Functions: next, sum, list, and sorted.
- Python tracemalloc: Trace Memory Allocations.
- Python contextlib: closing.
- Python asyncio: Coroutines and Tasks.
