Why can a Python 3 map, filter, zip or enumerate object be consumed only once?
answer
- Python 3 made these builtins lazy
- Ask what iter() returns for each
- One shared cursor, silent when drained
- zip pulls, and stops at the shortest
- list() at the boundary, or rebuild
basics
~20 sIn 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.
solid answer
~40 s`map`, `filter`, `zip`, `enumerate` and `reversed` all return iterator objects in Python 3 — Python 2's `map` and `filter` returned lists, which is where the expectation comes from. Being their own iterators, they share one cursor: `max(m)` followed by `list(m)` gives you the maximum and then an empty list. They also *pull* from their inputs, so `zip` over two references to the same iterator drains it in alternating steps. The fix is to decide at the boundary: wrap in `list()` if the result must be reused or measured, or rebuild the `map`/`zip` call for the second pass. Sequences, dict views and `range` objects do not have this problem, because each `iter()` on them creates a fresh iterator.
code
python · 8 linesnums = [3, 1, 4]
doubled = map(lambda n: n * 2, nums)
print(max(doubled)) # 8
print(list(doubled)) # []
pairs = zip([1, 2, 3], "abc")
print(list(pairs)) # [(1, 'a'), (2, 'b'), (3, 'c')]
print(list(pairs)) # []go deeper
Remember that in Python 3 map, filter, zip and enumerate hand back lazy objects, not lists. If you need to print, count and then loop, wrap the result in list() once and use that list everywhere.
Explain the mechanism: these objects are their own iterators, so iter(obj) is obj and one cursor is shared; the second consumer sees StopIteration immediately. Contrast with sequences and dict views, which mint a new iterator per iter().
Show the production instinct: normalise one-shot arguments at API boundaries, prefer zip(..., strict=True) where equal length is an invariant, and treat any diagnostic that consumes its subject as a defect waiting to ship.
Set the convention. Decide where laziness is worth its sharp edges, what return annotations must say about re-iterability, and how reviews catch the count-then-process pattern before a silent-truncation bug reaches users.
## What changed, and what it costs you In Python 2, `map()` and `filter()` returned lists and `zip()` returned a list of tuples. Python 3.0 made all of them lazy: they now return small iterator objects that compute items on demand. `enumerate()` and `reversed()` behave the same way, as do file objects and the generator objects produced by generator functions and generator expressions. The upside is memory and short-circuiting — `any(map(is_bad, huge))` stops at the first hit. The downside is that each of these results carries exactly one cursor and can be walked exactly once. ```python nums = [3, 1, 4] doubled = map(lambda n: n * 2, nums) print(max(doubled)) # 8 print(list(doubled)) # [] -- already drained ``` The reason is the same as for generators: `iter(doubled) is doubled`. A `for` loop, `list()`, `max()`, `sum()`, `any()` and the `in` operator all begin by calling `iter()`, and for these objects that call hands back the same half-consumed object rather than a fresh one. When the underlying source runs out, `__next__` raises `StopIteration` and keeps raising it. ## The failure is silent truncation, not an exception That is what makes it dangerous. A second pass over a drained `map` object does not blow up; it produces zero items. Counts come back as `0`, `list()` gives `[]`, `all()` gives `True`, membership tests give `False`. The wrong answer travels downstream looking exactly like a legitimately empty input, and it typically only appears once somebody adds a *second* consumer to code that worked fine with one. ## `zip` pulls, and that has two consequences First, `zip` stops at the shortest input. If one side is short, the extra items on the other side are dropped without a word — a silent truncation of a different flavour. Python 3.10 added `zip(a, b, strict=True)`, which raises `ValueError` when the inputs have different lengths; use it whenever equal length is an invariant rather than a hope. Second, because `zip` pulls from its arguments, feeding it the *same* iterator twice makes the two positions interleave rather than pair up with themselves: ```python it = iter([1, 2, 3, 4]) print(list(zip(it, it))) # [(1, 2), (3, 4)] ``` This is a known idiom for chunking into pairs, but it is a trap when it happens by accident: `zip(records, records)` over a list pairs each record with itself, while over an iterator it pairs neighbours. The difference is whether the argument is re-iterable. ## What is safe to loop repeatedly Re-iterable objects build a *new* iterator on each `iter()` call. Lists, tuples, strings, `bytes`, `range` objects, sets, dicts and the dict views returned by `keys()`, `values()` and `items()` are all in this camp — a dict view is a live window on the dict, and you can loop it as many times as you like. One-shot objects are those that *are* their own iterator: generators, `map`, `filter`, `zip`, `enumerate`, `reversed`, `iter(x)` results, open file objects, and most `itertools` results. The test that settles it for any object is a one-liner: `isinstance(value, collections.abc.Iterator)`, or `iter(value) is value`. Testing for `collections.abc.Iterable` tells you nothing useful here, since every iterator is also an iterable. ## Writing code that does not step in it Decide the contract at the boundary and be explicit about it. If a function's result will be counted and then processed, or sorted and then written, materialise it once — `rows = list(map(parse, raw))` — and work with the list. If the data is genuinely large and one pass is all you need, keep it lazy and *document* that the return value is one-shot, using an `Iterator[...]` annotation rather than the vaguer `Iterable[...]`. If a helper must accept either shape, normalise on entry: check for `Iterator` and call `list()` only then, so a caller passing a list does not pay for an unnecessary copy. Finally, be suspicious of any diagnostic that consumes the thing it is diagnosing. Logging `len(list(results))` before the real loop is the single most common way this bug gets introduced — the log line reports the right count and the loop that follows it processes nothing.
- How would you write a helper that works whether the caller passes a list or a map object?Normalise at the top of the function: if you need more than one pass, `if isinstance(data, Iterator): data = list(data)`. That copies only when the argument really is one-shot, so a caller passing a list pays nothing. If one pass is enough, just consume it once and never touch the argument again — and say so in the signature by annotating the parameter as `Iterable[...]` and the docstring as consumed-once.
- Which common results can be iterated repeatedly, and which cannot?Re-iterable: lists, tuples, strings, `range` objects, sets, dicts and the views from `keys()`, `values()` and `items()` — each `iter()` builds a fresh iterator. One-shot: generator objects, `map`, `filter`, `zip`, `enumerate`, `reversed`, open file objects and most `itertools` results, because each of those *is* its own iterator.
- What did Python 3.10 add to zip, and when should you use it?`zip(a, b, strict=True)` raises `ValueError` if the inputs run out at different points, instead of silently stopping at the shortest. Use it whenever equal length is an invariant of the data — parallel record columns, ids paired with values — so a mismatch surfaces as a loud failure at the zip rather than as quietly missing rows much further downstream.
saying these in an interview costs you the question
- Says map and filter return lists in Python 3
- Expects a TypeError when reusing a drained zip object
- Thinks list() on a map object can be repeated freely
- Assumes zip raises on unequal-length inputs by default
- Believes dict.items() is one-shot like enumerate
- Logs len(list(results)) before the real loop