Your feature-flag loader returns a generator and callers see silent truncation; how do you diagnose and fix it?
answer
- Nothing crashed, data just vanished
- Prove it with a two-pass test
- Find who consumed it first
- Decide the contract at the boundary
- List, factory, or tee with buffering
basics
~20 sConfirm 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.
solid answer
~50 sFirst reproduce it: iterate the loader's return value twice in a test and assert the two results match — a one-shot iterator fails that instantly, while an empty upstream source fails both passes equally, which separates the two hypotheses. Then check `isinstance(result, collections.abc.Iterator)` at the call site to confirm what is being handed around, and look for the classic trigger: a count, a validation pass or a log line that consumes the values before the real loop. The fix is a contract decision. Flag data is bounded, so returning `list(...)` is the cheapest correct answer. If laziness must be kept, return a factory object whose `__iter__` yields a fresh generator per loop, or use `itertools.tee` for two consumers that run roughly in step — accepting that `tee` buffers everything the lead branch has taken and the lagging one has not.
code
python · 13 linesfrom collections.abc import Iterator
def load_flags(rows):
return (row.strip() for row in rows if row.strip())
def publish(flags):
if isinstance(flags, Iterator):
flags = list(flags)
print("publishing", len(flags), "flags")
for name in sorted(flags):
print(" -", name)
publish(load_flags([" beta_ui ", "", "new_pricing"]))go deeper
Focus on recognising the shape: values present on the first pass and gone on the second means something consumed them. Wrapping the loader's result in list() once, and using that list everywhere, fixes the immediate bug.
Be able to trace it: identify the first consumer between loader and loop, confirm with isinstance(value, collections.abc.Iterator), and explain why a list comprehension and a generator expression differ only in brackets but not in cost of this bug.
Demonstrate judgement about the contract. Argue why bounded flag data should simply be returned as a list, when a factory object is worth its extra class, and what itertools.tee actually buffers before you recommend it.
Frame it as a class of defect, not an incident. Decide the house rule on returning iterators from public functions, how annotations express re-iterability, and what review and test conventions stop a silent-truncation bug from riding a long release train.
## Reading the symptom Silent truncation with no exception is the signature of a drained iterator. The loader hands back a generator object; something upstream of the real work walks it — a count for a metric, a validation sweep, a debug line reporting how many flags were loaded — and by the time the publishing loop runs there is nothing left. Because exhaustion is not an error, the service publishes an empty or partial flag set and reports success. On a three-week release train, a defect like this is expensive precisely because it is quiet: nothing crashes, and the missing rollout is noticed by a human days later. ## Diagnosing it in the right order **1. Separate "nothing was loaded" from "something ate it".** Write a test that consumes the loader's result twice and compares. An empty source yields empty both times; a one-shot iterator yields the full set then nothing. This single test both proves the hypothesis and becomes the regression guard. **2. Confirm the object's nature, not its label.** At the boundary, `isinstance(result, collections.abc.Iterator)` is true exactly for objects that are their own iterator — generators, `map`, `filter`, `zip`, `enumerate`, file objects. `iter(result) is result` says the same thing. Do not test for `Iterable`: every iterator is one, so the check always passes and proves nothing. **3. Find the first consumer.** Search the path between the loader and the failing loop for anything that iterates: `len(list(...))`, `sum(1 for _ in ...)`, `any(...)`, `sorted(...)`, `"x" in ...`, a metrics counter, or simply passing the same object to two helpers. The first consumer is nearly always something that was added for observability, which is why the bug so often arrives with a logging change rather than with the feature itself. **4. Check for accidental laziness deeper down.** A loader that ends in `return (row.strip() for row in rows)` or `return map(parse, rows)` is returning a one-shot object even though the function reads like it returns a collection. Comprehension brackets matter: `[...]` builds a list, `(...)` builds a generator object. ## Choosing the fix This is a contract decision, not a patch. **Return a container.** Flag definitions are bounded — hundreds of small records, not a stream. `return [row.strip() for row in rows]` costs a trivial amount of memory and buys a value that can be counted, indexed, sorted and looped as often as any caller likes. When the data fits, this is the correct answer and the one to reach for first. **Return a factory.** If the source is genuinely large and must stay lazy, do not return the iterator — return an object that *makes* one on demand, with `__iter__` building a fresh generator each call. Callers get container-like semantics with streaming memory behaviour, and the failure mode disappears rather than being documented around. **Normalise defensively at the boundary.** In the consumer that needs two passes, `if isinstance(values, Iterator): values = list(values)`. This copies only when the argument really is one-shot, so callers passing a list pay nothing. **`itertools.tee`, with eyes open.** `a, b = tee(source, 2)` gives two independent branches over one underlying iterator. It is the right tool when two consumers advance at roughly the same rate. It is the wrong tool when one branch runs far ahead: `tee` must buffer every item the lead branch has consumed and the lagging one has not, so a count-everything-then-process pattern buffers the entire stream — all the memory of `list()`, plus indirection. And once you tee, the original iterator must not be touched again; advancing it steals items from both branches. **Never try to reset.** There is no rewind on a generator object. Any "just seek it back" suggestion is a misconception worth correcting on the spot. ## Making the class of bug unrepeatable Say it in the types: annotate a return as `list[str]` when you promise re-iteration and `Iterator[str]` when you do not. `Iterable[str]` is the ambiguous middle and should be reserved for parameters, where accepting either is the point. A type checker will not catch double consumption, so the annotation is documentation with teeth rather than enforcement — which is why the two-pass test matters more than the annotation does. Then add the guard rails: a test per public loader that iterates twice; a review habit of treating any diagnostic that consumes its subject as a defect; and, where a mismatch would matter, `zip(..., strict=True)` (Python 3.10+) so unequal-length inputs raise instead of truncating. The theme is the same at every level — make the one-shot nature of a value either impossible or loud, never quiet.
- Why is itertools.tee not a general substitute for calling list() on the source?`tee` buffers exactly the window between its fastest and slowest branch. Two consumers stepping together buffer almost nothing; one consumer that counts everything before the other starts buffers the whole stream — the memory of `list()` plus extra indirection and a subtler failure mode. It also forbids further use of the original iterator, since advancing it steals items from every branch.
- How should the loader's return type be annotated so callers know what they are getting?Annotate `list[str]` (or another concrete container) when you promise re-iteration, and `Iterator[str]` when the result is one-shot. Reserve `Iterable[str]` for parameters, where accepting either shape is the point. No type checker enforces single consumption, so treat the annotation as a contract statement and back it with a test that iterates the return value twice.
- What single test would have stopped this reaching production?One that calls the loader, materialises the result twice and asserts the two passes are equal. A one-shot return fails it immediately, while a genuinely empty source fails both passes identically — so the test distinguishes the two causes as well as catching the regression. Adding it to every public loader makes the whole class of bug non-recurring.
saying these in an interview costs you the question
- Suggests resetting or rewinding the generator object
- Reaches for itertools.tee before considering list()
- Checks isinstance against Iterable to detect one-shot values
- Keeps using the original iterator after calling tee
- Blames an empty upstream source without a two-pass test
- Calls the return a list because the function name says load