Why does a generator expression still yield the old list after the source name is rebound in a document-conversion queue?
answer
- Something runs before the first next()
- The object is captured, the name is not
- Only the first for clause is eager
- Errors from the source appear at creation
- Mutating the captured list is still visible
basics
~20 sThe outermost iterable is evaluated eagerly, in the enclosing scope, when the generator is created, and the resulting object is handed to the generator. Rebinding the name afterwards moves the name only; the generator still holds the original list.
solid answer
~50 sA generator expression is compiled to a hidden generator function that takes one argument: an iterator over its **outermost** iterable. That iterable is evaluated immediately when the expression is created, in the enclosing scope — everything else (the output expression, any `if` filter, any later `for` clause) is evaluated lazily on each step, inside the generator's own scope. So `pending = (convert(p) for p in batch)` captures the *object* `batch` referred to at creation time; a later `batch = next_batch` rebinds the name only, and the generator keeps producing from the original list. Mutating that same list object, by contrast, is visible, because the generator holds the object. Two related consequences: a `TypeError` or `NameError` from the outermost iterable surfaces at creation rather than at first `next()`, and names used elsewhere in the expression are read late, so later changes to them do show up.
code
python · 9 linesbatch = ["a.doc", "b.doc"]
pending = (name.upper() for name in batch)
batch = ["c.doc"]
print(list(pending)) # ['A.DOC', 'B.DOC']
batch = ["a.doc"]
pending = (name.upper() for name in batch)
batch.append("b.doc")
print(list(pending)) # ['A.DOC', 'B.DOC']go deeper
Recall that creating a generator expression already does one thing: it evaluates the first for clause's iterable. Everything else waits for iteration. Be able to predict the output when the source name is reassigned before the generator is consumed.
Explain the compiled shape: an implicit generator function receiving an iterator over the outermost iterable as its argument. Use that to explain why rebinding the source name is invisible while mutating the source object is not, and why a TypeError from the source appears at creation.
Demonstrate the diagnosis. Distinguish a stale-capture symptom from an arithmetic one, know that failures split across creation and consumption so tracebacks surface far from the expression, and prescribe the fix: build it where it is consumed, or pass the source as a parameter, or snapshot it on purpose.
Own the boundary rule. Decide whether lazy sequences may cross module or service boundaries in your codebase at all, given that a generator carries a half-frozen snapshot of state whose lifetime nobody at the receiving end can see, and weigh that against the memory the eager alternative costs.
## The split: one eager part, everything else lazy A generator expression compiles to an implicit generator function. The compiler evaluates the outermost iterable — the iterable of the *first* `for` clause — in the enclosing scope, calls `iter()` on it, and passes the resulting iterator into the hidden function as its single argument. Only then do you get a generator object back. Nothing else has run: the output expression, any `if` filter, and the iterables of any second or later `for` clause are all evaluated inside the generator, on demand, once per step. That one eager evaluation is what the scenario turns on. A document-conversion queue builds `pending = (convert(p) for p in batch)`, then rebinds `batch` to the next batch of pages before draining `pending`. Rebinding a name changes what the name refers to; it does not reach into an object that already holds a reference to the old list. The generator therefore converts the original pages. A four-person team can lose most of a day to this, because the symptom — per-page scale totals that do not match the pages they think they queued — looks exactly like a floating-point rounding drift, and rounding is the first thing anyone suspects when totals are close but wrong. The tell that it is not rounding: the discrepancy is not small and it is not proportional to the number of pages. It is a whole wrong batch. ## Name capture versus object capture The distinction that actually predicts behaviour: ```python batch = ["a", "b"] pending = (name.upper() for name in batch) batch = ["c"] # rebinding the name: invisible to the generator print(list(pending)) # ['A', 'B'] batch = ["a"] pending = (name.upper() for name in batch) batch.append("b") # mutating the captured object: visible print(list(pending)) # ['A', 'B'] ``` The generator holds the iterator over the object that existed at creation. Rebinding is invisible, mutation is not. This is the same rule as anywhere else in Python, but it surprises people here because the surrounding construct *looks* lazy end to end. The inverse also holds, and it bites in the other direction: any *other* name in the expression is read late. `factor` in `(p * factor for p in batch)` is looked up when each item is produced, so a change to `factor` between creation and consumption does change the output. A generator expression is therefore half-frozen — its source object is fixed, its free variables are not — and that mixture is the real trap. ## Error timing Because the outermost iterable is evaluated and `iter()`-ed at creation, its failures happen at creation: ```python try: g = (x for x in None) except TypeError as exc: print(exc) # 'NoneType' object is not iterable ``` The same is true of a `NameError` for an undefined source name, or an exception raised by a function call that produces the source. But a failure anywhere *else* — in the filter, the output expression, or a later `for` clause — does not surface until iteration, and then it surfaces at whatever call site happens to be draining the generator, often far from where the expression was written. Splitting error handling across creation and consumption like this is the main reason generator expressions are harder to debug than the list comprehension they replace. ## Interaction with mutation during consumption Since the generator keeps a live iterator over the source object, mutating that object *while* the generator is being drained behaves exactly as it would in a `for` loop over it: a list can shift items under the iterator and silently skip or repeat entries, and a `dict` or `set` raises `RuntimeError` if its size changes during iteration. Producer-consumer queues where one side appends while the other drains a generator expression are a reliable source of this. ## How to write it so it cannot happen Three defences, in order of preference. Build the generator expression where it is consumed rather than passing it across a boundary, so there is no window in which the source can change. Where it must be passed, take the source as a *parameter* of a real generator function, which makes the capture explicit at the call site. And where the source is genuinely volatile, snapshot it deliberately — `tuple(batch)` or `list(batch)` — and say in a comment that the snapshot is the point. Materialising the whole comprehension with `list(...)` is the fourth option and is the right one whenever the source is small and the laziness was never buying anything. ## Does this apply to a list comprehension? The same eager evaluation of the outermost iterable happens, but a list, set or dict comprehension runs to completion immediately, so there is no window between creation and consumption in which the difference could be observed. The one place it *is* observable is a class body, where only the outermost iterable is evaluated in the class namespace.
- Which parts of a generator expression are evaluated lazily?Everything except the outermost iterable: the output expression, every `if` filter, and the iterable of any second or later `for` clause. They run inside the generator's own scope, once per step, reading free variables at that moment. So a change to a captured free variable between creation and consumption does affect the output, even though a rebinding of the source name does not.
- How would you make the capture safe when the generator expression must cross a function boundary?Prefer building it where it is consumed. If it must be passed, wrap it in a real generator function that takes the source as a parameter, so the capture is explicit at the call site. If the source is volatile, snapshot it deliberately with `tuple(...)` or `list(...)` and say so in a comment — and if the source is small, just materialise the whole result and drop the laziness.
- What happens if the source object is mutated while the generator is being drained?The generator holds a live iterator over that object, so it behaves like a `for` loop over it. Appending to or deleting from a list shifts items under the iterator and can silently skip or repeat entries; changing the size of a `dict` or `set` during iteration raises `RuntimeError`. Producer-consumer code that appends on one side while draining on the other hits this routinely.
Creating the generator is like handing someone a specific folder from your desk. Putting a different folder where that one sat does not change what they are holding; adding pages to the folder they hold does.
saying these in an interview costs you the question
- Says nothing at all runs until the first next() call
- Claims the generator tracks the variable name, not the object
- Thinks mutating the captured list is also invisible
- Blames float rounding without checking what was captured
- Says free variables are frozen at creation like the source
- Assumes a list comprehension would behave differently here