skip to content

Generator Functions

A def with yield returns a generator that runs a little, suspends with its locals intact, and resumes on the next next(). Interviewers probe laziness, memory, and the paused frame as a real object.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

23

Why does a second for loop over the same generator object produce nothing?

level: juniorimportance: must knowfreq 72%

answer

  1. The second loop sees an empty source
  2. Ask what iter() gives back each time
  3. One cursor, and it only moves forward
  4. StopIteration means done, not error
  5. No rewind: rebuild, materialise or tee

basics

~20 s

A generator object is its own iterator and holds one position. Once it has raised StopIteration it is exhausted for good, so a second for loop starts at the end and finishes immediately, silently and without an error.

solid answer

~40 s

Calling `iter()` on a generator object returns that same object, so there is only ever one cursor into it. The first loop advances that cursor to the end; from then on every `next()` raises `StopIteration` again, and a `for` loop treats that as "done" rather than an error, so the second loop body simply never runs. A list behaves differently because `iter(a_list)` hands back a **fresh** iterator each time, which is why lists, tuples, `range` objects and dict views can be looped repeatedly. Generators cannot be rewound or reset. If you need the values twice, either materialise them once with `list(...)`, call the generator function again to get a new generator object, or hand callers a factory object whose `__iter__` builds a fresh generator per loop.

code

python · 9 lines
python
def flag_names(rows):
    for row in rows:
        yield row.strip()

gen = flag_names([" beta_ui ", "new_pricing"])
print(list(gen))          # ['beta_ui', 'new_pricing']
print(list(gen))          # []
print(sum(1 for _ in gen))  # 0
print(iter(gen) is gen)   # True -- one shared cursor

go deeper

for a junior

Recall the headline: a generator object can be looped once. Be able to show the two-loop snippet, say that the second loop prints nothing and raises nothing, and name list() as the simplest fix when you need the values twice.

for a middle

Explain the mechanics: iter(gen) is gen, so one cursor is shared; once the body returns, StopIteration repeats forever and the frame is gone. Contrast that with iter(a_list) returning a fresh iterator on every call.

for a senior

Show how you keep this out of production: honest return annotations, an Iterator check plus list() at API boundaries that need two passes, and a regression test that iterates a returned value twice and asserts equal results.

for a principal

Own the contract question. Decide as a matter of house style whether internal functions may return one-shot iterators at all, where the materialise-or-stay-lazy boundary sits given memory budgets, and how that expectation is expressed in types and reviewed.

## The symptom The bug looks like data loss. A function builds a generator object, one piece of code loops over it to count or validate, a second piece of code loops over it to do the real work, and the second loop sees nothing at all. No exception, no warning, no traceback — just an empty result where a full one was expected. ```python def flag_names(rows): for row in rows: yield row.strip() gen = flag_names([" beta_ui ", "new_pricing"]) print(sum(1 for _ in gen)) # 2 print(list(gen)) # [] -- the values are gone ``` ## Why it happens A `for` loop begins by calling `iter()` on whatever it is given. For a list, `iter()` constructs a brand-new `list_iterator` positioned at index 0, so every loop is independent. For a generator object, `iter()` returns *the generator object itself* — a generator is its own iterator, and `iter(gen) is gen` is `True`. There is therefore exactly one cursor, shared by every consumer, and it only moves forward. Each `next()` resumes the suspended function body until the next `yield`. When the body finally returns, `next()` raises `StopIteration`, and the generator marks itself finished and drops its execution frame. Every subsequent `next()` raises `StopIteration` immediately. There is no `reset()`, no `seek(0)`, no way back: the state that would let it replay the values no longer exists. ## Why it is silent `StopIteration` is the normal end-of-iteration signal, not an error. `for` catches it and exits cleanly, so a loop over an exhausted generator is indistinguishable from a loop over an empty source. The consequences are quiet and wrong rather than loud: `sum()` returns `0`, `any()` returns `False`, `all()` returns `True`, `list()` returns `[]`, `"x" in gen` returns `False`, and `len(list(gen))` reports zero items where the data really did exist a moment earlier. `max()` and `min()` are the only common builtins that complain, and only because an empty sequence has no maximum. The patterns that trigger it in real code are always the same shape: count first and then process; validate first and then write; log a preview and then send; or simply pass the same generator object to two helper functions. ## The four ways out **1. Materialise it.** If the data is bounded and small enough to hold, `values = list(gen)` once, then reuse `values` freely. This is the right default for hundreds or thousands of small records; it trades memory for a container that can be measured, indexed and looped as often as you like. **2. Rebuild it.** Call the generator function again. `flag_names(rows)` is cheap; each call returns a new generator object with a fresh frame. This works only if the underlying source is itself re-readable — rebuilding over an already-drained iterator just produces another empty generator. **3. Hand out a factory, not an iterator.** Return an object that builds a new generator on every `__iter__` call. Callers then get list-like "loop me as often as you want" semantics while the values stay lazy: ```python class FlagNames: def __init__(self, rows): self._rows = rows def __iter__(self): return (row.strip() for row in self._rows) ``` **4. `itertools.tee`.** When two consumers genuinely need the same live stream, `tee` splits one iterator into independent branches — at the price of buffering whatever the leading branch has consumed and the lagging one has not. Useful for two consumers running roughly in step; a poor substitute for `list()` otherwise, and after teeing you must stop touching the original iterator. ## Defending against it Because the failure is silent, the cheap protection is to make the contract explicit. Annotate a return type as `list[str]` when you promise re-iteration and as `Iterator[str]` when you do not, name the parameter honestly, and at an API boundary that must survive being looped twice, check `isinstance(value, collections.abc.Iterator)` and materialise. Checking for `Iterable` proves nothing — every iterator is also an iterable; `Iterator` is the check that says "this argument is one-shot". And write the test that iterates the result twice: that single test turns an invisible data-loss bug into a red build.

  • How can you tell, before consuming it, whether an argument is a one-shot iterator or a re-iterable container?
    Check `isinstance(value, collections.abc.Iterator)`, or equivalently `iter(value) is value`. Both are true exactly for objects that are their own iterator — generators, `map`, `filter`, `zip`, `enumerate`, file objects. Checking `collections.abc.Iterable` proves nothing, because every iterator is also an iterable. If the check says iterator and you need more than one pass, materialise with `list()` at the boundary.
  • What happens if you call next() on a generator object that has already raised StopIteration?
    It raises `StopIteration` again, immediately, forever. When the generator body returns, the generator drops its frame and marks itself finished, so there is no state left to resume. Nothing resets it — you have to call the generator function again to get a new generator object, and that only helps if the underlying source can also be read again.
  • Why is this bug so often reported as missing data rather than as a crash?
    Because exhaustion is not an error condition. `StopIteration` is the protocol's normal end signal, so `for`, `list()`, `sum()` and `any()` all treat an exhausted generator exactly as they treat an empty one: zero items, no complaint. The wrong answer flows downstream unchallenged, which is why an explicit second-iteration test is worth writing.

A list is a book you can reopen at page one; a generator object is a live ticker tape — once it has run through the reader, there is nothing left to read again.

saying these in an interview costs you the question

  • Says a generator restarts from the beginning on a new for loop
  • Claims the second loop raises StopIteration to the caller
  • Thinks calling iter() on a generator gives a fresh cursor
  • Believes a generator can be reset or rewound
  • Assumes an empty result must mean the source was empty
  • Uses isinstance against Iterable to detect a one-shot argument

context

open as a page

How does a generator expression's memory use differ from a list comprehension?

level: juniorimportance: must knowfreq 78%

basics

~20 s

A list comprehension builds every element and holds them all at once. A generator expression builds an object that produces one element per next() call, so peak memory tracks a single item rather than the whole result.

open as a page

What does `yield from` do inside a Python generator function?

level: juniorimportance: must knowfreq 70%

basics

~20 s

yield from <iterable> yields every value that iterable produces, replacing an explicit for-loop that re-yields inside a generator function. Unlike that loop, it also forwards send, throw and close to a subgenerator and binds its return value.

open as a page

What does calling a Python generator function return, and when does its body first run?

level: juniorimportance: must knowfreq 78%

basics

~20 s

Calling it runs none of the body. You get back a generator object, which is an iterator. The body starts only on the first next() call and runs up to the first yield, then pauses there.

open as a page

Why can a Python 3 map, filter, zip or enumerate object be consumed only once?

level: middleimportance: must knowfreq 58%

basics

~20 s

In Python 3 these builtins return lazy iterator objects rather than lists. Each is its own iterator with a single forward cursor, so the first consumer drains it and every later consumer sees an empty sequence.

open as a page

What does `gen.send(value)` do to a generator paused at a `yield`?

level: middleimportance: must knowfreq 52%

basics

~20 s

send(value) resumes the generator and makes value the result of the yield expression it was paused on. The generator runs to its next yield, and send returns that yielded value, or raises StopIteration if the generator finishes.

open as a page

What value does `result = yield from subgen()` bind in a Python generator?

level: middleimportance: must knowfreq 55%

basics

~20 s

It binds the subgenerator's return value, not its yielded values. Those went straight to the consumer. When the subgenerator finishes, its return value rides out on the StopIteration that ends it, and yield from unpacks it. No return statement means None.

open as a page

How does a Python generator preserve its local variables across a yield?

level: middleimportance: must knowfreq 60%

basics

~20 s

The generator object owns the call's frame. A yield suspends that frame instead of destroying it, so locals and the resume point survive. Resuming continues in the same frame; the frame is released only when the generator finishes.

open as a page

How does a generator used as a data source differ from one driven by its send() method?

level: juniorimportance: should knowfreq 30%

basics

~20 s

A source generator is pulled: each next() runs it to the next yield and hands a value out. A send()-driven generator is the mirror image - it parks at a yield expression and the caller pushes values in.

open as a page

Where does the value go when a generator function executes `return total`?

level: juniorimportance: should knowfreq 26%

basics

~20 s

It is not yielded. return total ends the generator and the value is attached to the StopIteration that the generator raises, as exc.value. A for loop or list() discards it silently; you see it only by catching StopIteration yourself.

open as a page

Why are generator-based consumer coroutines wrapped in a priming decorator?

level: middleimportance: should knowfreq 42%

basics

~10 s

A newly created generator has not reached its first yield, so feeding it a value raises TypeError. A priming decorator calls the generator function, advances the object once, and returns it ready to receive.

open as a page

What does calling `gen.throw(ValueError)` do to a generator paused at a `yield`?

level: middleimportance: should knowfreq 28%

basics

~20 s

It raises that exception inside the generator, at the exact yield where the frame is suspended, as if the yield itself had failed. If the body catches it and yields again, throw returns the new yielded value; otherwise the exception propagates to the caller and the generator closes.

open as a page

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

level: middleimportance: should knowfreq 48%

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.

open as a page

Why do len() and indexing fail on a Python generator object?

level: middleimportance: should knowfreq 54%

basics

~20 s

A generator object implements only iter and next, with no len and no getitem. It also cannot know how many items remain without running to the end, so the size is unknowable, not merely unimplemented.

open as a page

How does `yield from` forward a caller's send() and throw() to a subgenerator?

level: middleimportance: should knowfreq 45%

basics

~20 s

A generator suspended on yield from is a transparent pipe: a sent value resumes the subgenerator at its own yield expression, a thrown exception is raised there, and closing the outer closes the inner. A re-yielding loop forwards none of it.

open as a page

What happens when a Python generator function executes a return statement?

level: middleimportance: should knowfreq 46%

basics

~20 s

It ends the generator by raising StopIteration rather than yielding anything. Any returned value is not produced as an item; it is attached to that StopIteration. A for loop catches the exception and simply stops.

open as a page

Why did async def and await replace generator-based coroutines in Python?

level: seniorimportance: should knowfreq 38%

basics

~10 s

Because a generator-based coroutine was indistinguishable from an ordinary generator: same type, same interface, no compiler checks. Native coroutines added in 3.5 gave suspension its own type, syntax and error messages.

open as a page

Your feature-flag loader returns a generator and callers see silent truncation; how do you diagnose and fix it?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Confirm the loader's result is a one-shot iterator being consumed twice, then fix it at the boundary: return a list for bounded data, or an object whose iter builds a fresh generator. Add a test that iterates the result twice.

open as a page

How do you guarantee a generator's `finally` cleanup runs when an image-thumbnail worker abandons it half-consumed?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Close it explicitly rather than trusting the garbage collector: gen.close() raises GeneratorExit at the paused yield, which runs the generator's finally block. Wrap the consumer in contextlib.closing(gen) or a try/finally that calls close() so early exits cannot skip it.

open as a page

A 6-hour nightly webhook-log replay dies on memory - how do you restructure it to stream?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Find the stage that materializes - readlines(), a parsed list, a results accumulator, a sort - and make each one a generator stage so a single record flows end to end. Batch with itertools.batched to keep the final short batch.

open as a page

What breaks in a recursive `yield from` flattener on strings or deeply nested data?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Strings are the trap: iterating a one-character string yields that same string, so a flattener recursing into anything iterable never terminates and raises RecursionError. Deep nesting also hits the recursion limit, and per-item cost grows with delegation depth.

open as a page

A metrics scraper's generator function validates its config, but the error only appears once the consumer iterates. Why, and how do you make it raise at call time?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Because no statement of a generator function's body runs until the first next(), the validation is deferred to the consumer. Split the function: a plain function validates eagerly and returns the generator built by a private generator function.

open as a page

Why choose a send-driven generator pipeline over a pull-based one for an ETL export?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

Choose push when the rows arrive from something you do not drive, or when one row must reach several sinks. You pay for it: no backpressure, manual wiring, and one failing stage kills the whole chain.

open as a page