skip to content

One-Shot Iteration Traps

A generator is empty the second time you loop over it, and so are map, filter, zip and enumerate objects. The bug appears whenever a function is handed an iterator where it expected a container.

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

questions

3

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

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

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