skip to content

In a chained generator pipeline, when does each stage actually run?

level: middleimportance: should knowfreq 48%

answer

  1. Who decides when work happens
  2. Building the chain runs no bodies
  3. Demand travels upstream, values travel down
  4. One item crosses every stage first

basics

~20 s

Nothing runs until a consumer pulls. Building the chain only creates generator objects; each next() at the end pulls one item backwards through every stage, so the stages interleave item by item rather than each finishing in turn.

solid answer

~50 s

A generator pipeline is **pull-based**. `stage3(stage2(stage1(src)))` executes no function body - it just creates three generator objects wired together. When the consuming `for` loop asks for one item, the last stage resumes, asks its input for one item, which asks its input, down to the source; a single value then travels back up through every transformation before the loop body sees it. Two consequences matter in practice. First, work interleaves per item, not per stage, which is exactly why memory stays flat - no stage ever holds the whole collection. Second, everything is deferred to consumption time: side effects, log lines and exceptions surface inside the consuming loop, not at the line that built the pipeline, so a `try`/`except` wrapped around construction catches nothing. Breaking out early means the upstream work for the remaining items is never done at all.

code

python · 14 lines
python
def source():
    for n in range(3):
        print("source", n)
        yield n

def double(items):
    for n in items:
        print("double", n)
        yield n * 2

pipeline = double(source())   # nothing has run yet
print("built the pipeline")
for value in pipeline:
    print("consumed", value)

go deeper

for a junior

Recall the headline: chaining generator functions does not run them. The code inside starts only when a for loop or next() asks for a value, and it stops as soon as you stop asking.

for a middle

Be able to trace one item through the chain: the consumer calls next() on the last stage, demand travels upstream to the source, and a single value passes back through every transformation before the next item starts.

for a senior

Demonstrate the operational consequences: errors and side effects appear at consumption time, profiles attribute time to the consuming loop, early exit genuinely skips work, and one sorting or grouping stage turns the whole chain into a barrier.

for a principal

Own the design tradeoff between a lazy chain and explicit batches: laziness gives flat memory and free backpressure but scatters failure handling and resource lifetimes across consumption. Decide where a bounded buffer buys clearer error boundaries and restartability.

Composing generators into a pipeline is the standard Python idiom for transforming a stream, and its execution model surprises people the first time a print statement shows up in an order they did not expect. ## Building is not running ```python def read(rows): for row in rows: yield row def clean(rows): for row in rows: yield row.strip() def keep(rows): for row in rows: if row: yield row pipeline = keep(clean(read(source))) ``` After that last line, **not one line inside `read`, `clean` or `keep` has executed**. Calling a generator function does not run its body; it creates a generator object with a suspended frame parked before the first statement. Three calls produce three generator objects, each holding a reference to the one upstream. That is the entire cost of construction. ## Pull, one item at a time Execution starts when something consumes the last object in the chain. `for row in pipeline:` calls `next()` on `keep`'s generator. `keep` resumes and immediately needs an item, so it calls `next()` on `clean`. `clean` resumes and calls `next()` on `read`. `read` resumes, pulls one row from the source, and yields it - and now the value climbs back: `clean` strips it and yields, `keep` tests it and yields, the loop body receives it. Then the loop asks again and the whole sequence repeats for item two. So the ordering is **item-major, not stage-major**. Stage one does not process the entire input before stage two begins. At any instant, exactly one item is in flight, plus whatever each stage keeps in its own locals. That is the mechanical reason a chain of ten generators over a ten-gigabyte input still runs in flat memory: nowhere in the chain does a full collection exist. The direction of control is the "pull" in pull-based: the consumer sets the pace and demand travels upstream. Nothing is pushed at the consumer; a slow consumer simply calls `next()` less often, and the upstream stages sit suspended doing nothing at all. Backpressure is free, because there is no buffer to overflow. ## What defers with the data Because the bodies run at consumption time, everything they do runs then too. **Exceptions.** If `clean` raises on row 4000, the traceback surfaces from the `for` line that was draining the pipeline, not from the line that built it. A `try`/`except` wrapped around construction catches nothing. This regularly confuses people who put pipeline setup in a defensive block and then wonder why errors escape. **Side effects.** A stage that opens a connection, writes a log line or increments a counter does so during consumption, so the ordering of those effects interleaves with the consumer's own work rather than happening in a neat block. **Early exit is a real saving.** `break` out of the loop, or take a few items with `itertools.islice`, and the upstream stages never compute the rest. A pipeline that would have processed a million rows might do a hundred rows of work. The list-building equivalent has already paid for all of them. **Cleanup.** When a pipeline is abandoned and collected, each generator is closed, and a `finally` in a stage runs then - which may be much later than the code that built it expects. If a stage owns a resource, drive it under a `with` block or close it explicitly rather than trusting the timing. **Profiling.** Time appears to be spent in the consuming loop, since that is where the frames resume. Reading a flat profile of a lazy pipeline without knowing this leads people to blame the wrong function. ## Where the model breaks Any stage that must see everything before it can emit anything destroys the property for the whole chain: a stage that sorts, that dedupes into a set, that groups by key, or that simply appends to a list and yields at the end. Such a stage becomes a barrier - upstream runs to completion, memory spikes to the size of what it holds, and only then does the downstream half start. Sometimes that is required, and the right answer is to bound it: sort within batches, or cap the buffer, rather than pretending the chain is still lazy. ## The interview answer Say: building the chain runs nothing; the consumer pulls; one item travels through all stages before the next one starts; therefore memory is flat, errors and side effects appear at consumption, and stopping early skips the remaining upstream work entirely.

  • Why does wrapping the construction of a generator pipeline in try/except catch nothing?
    Because construction only creates generator objects - no stage body has executed, so no stage can have raised. The bodies run when the consumer calls `next()`, so the exception propagates out of the consuming loop instead. The handler has to wrap the iteration, not the assignment.
  • What happens to the upstream stages if the consumer breaks out of the loop early?
    They simply stop being asked, so the remaining items are never produced - real work is saved, not just memory. The abandoned generator objects are closed when they are collected, which raises `GeneratorExit` inside each suspended frame so `finally` blocks can release resources. Because that timing is not guaranteed to be prompt, close resource-owning stages explicitly.
  • How do you spot the stage that breaks a pipeline's flat memory profile?
    Look for any stage that cannot emit until it has seen everything: sorting, grouping, deduplicating into a set, reversing, or appending to a local list and yielding at the end. Those are barriers - upstream runs to completion and memory spikes there. If one is unavoidable, bound it by batching rather than letting it hold the whole stream.

It is a bucket chain rather than an assembly line: nobody moves until the person at the end reaches out, and one bucket travels the whole chain before the next is picked up.

saying these in an interview costs you the question

  • Thinks each stage completes fully before the next begins
  • Expects stage bodies to run when the pipeline is built
  • Wraps pipeline construction in try/except to catch stage errors
  • Believes generator stages run concurrently or in threads
  • Cannot explain why memory stays flat across many stages
  • Assumes breaking out early still does the upstream work

context