# Trace exhausted Python iterators and shared mutable defaults

> Follow two stateful Python bugs line by line: a generator partially consumed by any() before sum(), and a default list shared across calls.

- Canonical HTML: [https://unrust.dev/blog/python-exhausted-iterators-and-mutable-defaults](https://unrust.dev/blog/python-exhausted-iterators-and-mutable-defaults)
- Markdown representation: [https://unrust.dev/blog/python-exhausted-iterators-and-mutable-defaults/index.md](https://unrust.dev/blog/python-exhausted-iterators-and-mutable-defaults/index.md)

Published: 2026-10-22. By the Unrust team.

[Open the companion tool](https://unrust.dev/tools/python-iterator-default-explorer/index.md)

## 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).

```python
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.

```python
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

[Python Tutorial: default argument values](https://docs.python.org/3/tutorial/controlflow.html#default-argument-values)
[Python Glossary: iterator vs iterable](https://docs.python.org/3/glossary.html#term-iterator)

[Work through the Python iterator consumption challenge](https://unrust.dev/guides/python-iterator-consumption-challenge/index.md)

[Take the free diagnostic without tools or hints](https://unrust.dev/diagnostic/index.md)
