Trace exhausted Python iterators and shared mutable defaults
Published
By the Unrust team · 6 min read · Editorial method
Follow two stateful Python bugs line by line: a generator partially consumed by any() before sum(), and a default list shared across calls.
Open the free companion tool →Why a second pass over an iterable can change its answer
A Python function annotated with Iterable[int] can receive a list, a tuple, a set, a file stream or a generator expression. Lists and tuples store their items and produce a fresh iterator each time you loop over them. A generator expression is already an iterator: advancing it consumes items from a single cursor.
In the companion explorer, start in Two-pass iterator mode with the generator (n for n in [12, 18, 25]) and the check any(n >= 18 for n in records). The first pass inspects 12 (false), then 18 (true), and short-circuits immediately. When sum(records) runs on the next line, the generator resumes after 18 and sums only [25], returning (True, 25) instead of (True, 55).
records = (n for n in [12, 18, 25])
first_check = any(n >= 18 for n in records)
total = sum(records)
print((first_check, total)) # (True, 25)Partial consumption is harder to spot than full exhaustion
Switch the first pass in the explorer to any(n >= 30 for n in records) or max(records). Now the first pass scans all three numbers before finishing. When sum(records) runs afterward, the generator is completely empty and returns 0 without raising an exception.
Partial consumption from a short-circuiting check like any() or next() is often subtler than full exhaustion because the second pass still returns a non-zero number — just one missing the prefix that the first pass read. Converting the input to a sequence at the function boundary, or switching the caller to a list or tuple, makes both passes read the same values.
When a function default persists across calls
Switch the explorer to Mutable default argument mode and select def record_step(step, history=[]):. In Python, default parameter expressions are evaluated once when the def statement executes, not each time the function is called. The default list [] is bound to the function object itself.
On the first call, record_step('parse') appends to that shared list and returns ['parse']. On the second call, record_step('validate') omits history again, so it receives the same list object that already holds 'parse' and returns ['parse', 'validate']. Only the third call, which explicitly passes ['seed'], mutates a caller-provided list instead.
def record_step(step, history=[]):
history.append(step)
return history
record_step('parse') # ['parse']
record_step('validate') # ['parse', 'validate']Use None as a sentinel when each call needs a fresh container
Change the signature in the explorer to def record_step(step, history=None):. Inside the body, if history is None: history = [] allocates a brand-new list whenever the caller omits the argument, while still allowing a caller to pass an existing list when accumulation is intentional.
Both bugs share the same underlying review habit: instead of reading each line in isolation, ask which object a variable name points to and whether an earlier pass or call has already mutated its state.
References and next steps
Try the reasoning without the tool
Explain one fresh example before checking its output. For a broader starting point, take the free diagnostic with tools and hints closed. A free account then gives you a daily practice queue.