Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·40 MIN

Iterators and generators: one method apart, and one silent bug

An iterable can hand you an iterator; an iterator can hand you the next item. That is the whole difference — one method. And out of it grows a bug that never raises: a function that walks its argument twice gets nothing the second time and returns a wrong answer without a sound.

Full technical treatment

TL;DR

An iterable can hand you an iterator (__iter__). An iterator can hand you the next item (__next__) and must hand back itself from __iter__. One method of difference — and one difference in behaviour: a list can be walked twice, an iterator cannot. A generator is an iterator written as a function, and calling a generator function does not run its body: it builds an object and returns.

Hence the main consequence, and it is not about performance. A function that walks its argument twice returns {'total': 19, 'negative': 2} for a list and {'total': 0, 'negative': 2} for a generator. No exception. Zero instead of nineteen.

Beyond that come the numbers, the versions and the limits of the trade. When the alternative materialises every result, a generator usually cuts the extra memory dramatically: on the squares of the numbers from zero to a million, the list ([i * i for i in range(1_000_000)]) takes 38.57 MiB against 0.5 KiB for the generator — seventy-nine thousand times less. Where there is nothing to materialise there is no win: summing over an already existing list costs 48 B, while the same sum through a generator expression costs 400 B. In time the sign depends on the size: on ten elements creating and summing through a generator costs more than through a list comprehension — 375 ns against 238 (3.13.7) — and the reason is structural: comprehensions have been inlined since 3.12, generator expressions have not.

Where to start
Before this lesson it is enough to understand
  • a for loop walks a list and gives out its items one at a time;
  • a function may return a value, or may run and return nothing;
  • a list of a million numbers takes up memory, a list of ten barely does.
You do not need to know in advance
  • __iter__, __next__, StopIteration, yield, generator expressions;
  • send, throw, close, yield from, async iteration, inlined comprehensions.

Base: what the loop actually gets

Start with code everyone has written:

PYTHON
for x in [10, 20, 30]:
    print(x)

The list sits in memory whole, and the loop takes items out of it one at a time. The question this topic starts from sounds almost dull and turns out to be the central one: what exactly does the loop take from the list?

Not the items. A list has no way to give out "the next one": it has no notion of a current position and no memory of where the last pass stopped. The walk needs both — and neither lives in the list.

So there are two steps here rather than one:

  1. the loop asks the list for something to walk along — and the list hands out a new one, separate from itself;
  2. then the loop asks that thing for the next value, over and over, until the values run out.

Both sides have names. An object you can ask for a walk is an iterable: a list, a string, a dict, an open file. What it hands out, and what remembers the position, is an iterator. Which is exactly why the protocol has two methods: __iter__ — "give me something to walk along", __next__ — "give me the next one".

The main difference in behaviour follows immediately. A list is a source of walks: every new loop asks it for a new iterator and starts from the beginning. An iterator is the walk itself, and there is only one of it: having reached the end, it stays at the end. A second loop over it does not start again — it simply gives nothing.

That is already enough to answer the basic interview question. Everything below is about that "gives nothing" happening silently, with no exception; about the generator, which is an iterator written as a function; and about the trade of memory for time being measurable in both directions.

Mechanism 1: one method apart

language contractLanguage guarantee: an iterator is __iter__ plus __next__, and iter(iterator) is iterator. One-shot behaviour follows from that.

The glossary definitions are short. An iterable is "an object capable of returning its members one at a time". An iterator is "an object representing a stream of data" whose repeated __next__ calls return successive items until the data runs out and StopIteration is raised.

What actually distinguishes them:

PYTHON
xs = [1, 2, 3]
it = iter(xs)
 
iter(xs) is xs          # False — a list hands out a NEW iterator
iter(it) is it          # True  — an iterator hands back itself
hasattr(xs, "__next__") # False — a list has no way to "give the next one"
hasattr(it, "__iter__") # True  — an iterator has both methods

Everything else follows. A list is a source of iterators, so you can walk it as many times as you like, from the start each time. An iterator is the stream itself, and there is only one of it:

PYTHON
list(it)    # [1, 2, 3]
list(it)    # []          — same object, already exhausted
list(xs)    # [1, 2, 3]
list(xs)    # [1, 2, 3]   — a fresh iterator every time

The glossary puts the second case better than anyone could:

Attempting this with an iterator will just return the same exhausted iterator object used in the previous iteration pass, making it appear like an empty container.

Appear like an empty container. Not "raise", not "warn" — appear empty. That is the root of everything below.

Mechanism 2: the bug that does not raise

Take a function that walks its argument twice. Nothing exotic — an ordinary report:

PYTHON
def report(rows):
    bad = [r for r in rows if r < 0]
    total = sum(rows)
    return {"total": total, "negative": len(bad)}
 
data = [10, -3, 5, -1, 8]
 
report(data)                  # {'total': 19, 'negative': 2}
report(r for r in data)       # {'total': 0,  'negative': 2}

No exception. The second pass walked an exhausted iterator, found nothing, and sum honestly returned zero. The function ran, the report was produced, the number is wrong.

Worth internalising as a class rather than a case: a function that accepts "a sequence" must either walk it once or pin it down at the start.

PYTHON
def report(rows):
    rows = list(rows)         # one line that removes the whole class
    ...

That line has a price — the whole sequence now sits in memory — and that price is what the rest of this lesson is about. But "works and is slow" beats "fast and wrong".

The same trap in miniature:

PYTHON
g = (i for i in range(5))
3 in g          # True
list(g)         # [4] — the check ate 0, 1, 2 and 3
len(g)          # TypeError: object of type 'generator' has no len()

Mechanism 3: what in the standard library is single-use

The iter(x) is x marker from the previous section is not academic. Half of what people use daily satisfies it (bench/iteration/one_shot_types.py, identical output on 3.11–3.14):

typeiter(x) is xsingle-use
list, dict, set, str, range, dict.keys()Falseno
map, filter, zip, enumerate, reversedTrueyes
a generator, itertools.chainTrueyes
open(...), io.StringIOTrueyes

The documentation of each says so outright — map "Return an iterator", filter "construct an iterator", zip "returns an iterator of tuples". The word "iterator" in the description is the warning.

For files the same thing is recorded separately:

IOBase (and its subclasses) supports the iterator protocol, meaning that an IOBase object can be iterated over yielding the lines in a stream.

io — IOBase

The check is the same for all of them: the second pass is empty. (Here and below the script's output is abridged to the relevant lines and its labels are translated.)

2) map over the list [1, 2, 3]:
   first  list(m): ['1', '2', '3']
   second list(m): []

For a file the single use is reversible — the cursor can be moved back:

5) first  pass: ['a', 'b', 'c']
   second pass: []
   fh.tell(): 9 — the cursor is at the end, hence empty
   after fh.seek(0): ['a', 'b', 'c']

(The three lines the script writes are Cyrillic letters, two bytes each — hence nine and not six.)

(Labels translated from the script's output.) map, filter and zip have no such lever: the object itself has to be rebuilt.

And here is the shape in which this costs money:

8) sum and len over the same map:
   sum(values) = 60
   len(list(values)) = 0 -> nothing left to average
   total / count -> ZeroDivisionError: division by zero

The one place in this whole section where being single-use raises an exception — and it does not name the cause. A ZeroDivisionError instead of "you walked the iterator twice". Fixed by one line: values = list(values) before the first pass.

Mechanism 4: changing a collection while iterating over it

A separate kind of single use is an iterator that became wrong while you were walking it. What matters here is that Python behaves differently for different types, and the guard is not everywhere (bench/iteration/invalidation.py, identical output on 3.11–3.14).

Dicts and sets have a guard:

1) adding to a dict during iteration -> RuntimeError: dictionary changed size during iteration
5) adding to a set during iteration  -> RuntimeError: Set changed size during iteration

But the guard compares the length, not the contents. In Objects/dictobject.c the check is written as di->di_used != d->ma_used, and hence the hole:

3) replacing values during iteration: no error, the walk is complete: ['a', 'b', 'c']
4) delete + insert (the size did not change): no error
   what was walked: ['a', 'b', 'z'] | dict: {'a': 1, 'b': 2, 'z': 0}

Delete one, add another — the length is the same, no exception is raised, and the deleted key was never visited. So a RuntimeError is a signal, not a guarantee.

The guard is sticky, too: right next to it in the source stands di_used = -1 with the comment Make this state sticky.

9) the first next after the change -> RuntimeError
   size restored; the next next -> RuntimeError

A list has no guard at all, and that is worse, because it is silent:

6) removing from a list inside a for over that same list:
   visited:   ['a', 'c', 'e']
   left over: ['b', 'd', 'e']
   not one exception; 'b' and 'd' were never visited

7) next(it) -> a | the iterator's index is now 1
   after xs.remove('a') the list became ['b', 'c']
   next(it) -> c — because index 1 already points at it

(Labels translated from the script's output.) A list iterator holds an index. Remove an element to its left and everything shifts while the index does not, so one element is skipped. The classic "filter in place" that deletes half of what it should.

There is exactly one official recommendation, and it comes from the tutorial rather than the reference:

Code that modifies a collection while iterating over that same collection can be tricky to get right. Instead, it is usually more straight-forward to loop over a copy of the collection or to create a new collection.

The Python Tutorial — More Control Flow Tools

Both routes were checked and both give the right answer: for x in list(xs) and rebuilding with a comprehension. About the "internal counter" rule people often quote as a rule of the language, it is worth being blunt: it is not in the reference — the behaviour in points 6 and 7 is a property of the list iterator's implementation, not a written rule.

Mechanism 5: a generator — an iterator written as a function

The language reference states the transformation in one sentence: "Using a yield expression in a function's body causes that function to be a generator function".

The consequence takes three lines to check:

PYTHON
log = []
 
def h():
    log.append("body started")
    yield 1
 
obj = h()
log            # [] — the body did NOT run
next(obj)
log            # ['body started'] — only now

The call built an object and returned. Everything written in the body — argument checks, opening a file, hitting a database — will not happen until somebody asks for the first item. A function that validates its arguments and contains a yield does not validate them when called.

Mechanism 6: four states, and all four are observable

A generator's state is not a metaphor but a string inspect returns:

PYTHON
import inspect
k = (i for i in range(2))
inspect.getgeneratorstate(k)            # 'GEN_CREATED'
next(k); inspect.getgeneratorstate(k)   # 'GEN_SUSPENDED'
list(k);  inspect.getgeneratorstate(k)  # 'GEN_CLOSED'

There are four states, and the order between them explains everything the rest of the lesson calls a technique. A run of bench/iterators/states.py walks them:

Three things read off the picture, each of which is usually explained separately.

A suspended generator is a live frame. GEN_SUSPENDED means the body stopped at a yield and is holding its locals, its open files and its references. Hence both the memory in the next section and the fact that send can pass a value back into that very place.

GEN_RUNNING cannot be caught from outside. While the generator runs it holds control; only it can ask for its own state, from inside the body. That is not a limitation of the library but a consequence of the caller standing still at that moment.

Two different roads lead into GEN_CLOSED, and nothing tells them apart afterwards. The body ran out on its own — or it was closed from outside with close(). One state covers both, so "exhausted" and "closed" are indistinguishable after the fact: if the difference matters, you have to record it yourself.

Three different things with similar names

This is also the place to separate what conversation calls by one word:

what it iswhen the body runs
generator functiona function with a yield in its bodynever: the call builds an object
generator objectthe result of calling itone step per next
generator expression(x for x in ...) — syntax that yields the object directlythe same, step by step

The first is about the declaration, the second and third about the object. Confusing the first with the second is what produces "why did my generator print nothing": there is nobody to print until somebody asks for an item.

Mechanism 7: memory against time — the measurement

measured observationbench/iterators/memory_time.py and bench/iterators/memory_when_not.py, CPython 3.11–3.14. Peak memory is compared inside one set; the builds differ between versions.

Here is the trade generators exist for. Peak memory via tracemalloc, one million elements:

what is summedpeak memory
[i * i for i in range(1_000_000)]38.57 MiB
(i * i for i in range(1_000_000))0.5 KiB

The list peak is 38.57 MiB on all four versions; the generator never goes past a kilobyte on any of them (0.5 KiB on 3.11–3.13, 0.4 on 3.14). The order of the difference is tens of thousands of times, and it does not depend on the version. The reason is obvious — the list holds a million objects, the generator holds one suspended function.

On that same million the generator does not lose on time either; it wins: 45.6 ms against 59.1 for the list on 3.13.7. Building the whole list first costs more than not building it.

And now the part usually left out. On small data it is the other way round:

3.13.7, ten elementsgeneratorlist
create only121 ns150 ns
create and sum375 ns238 ns

Creating the generator is cheaper — there was no body to run. Walking it is dearer: every next resumes a suspended function, and ten resumptions cost more than one loop over a finished list.

So the rule is written from its limit rather than from a number: when the alternative materialises every result, a generator usually cuts the extra memory dramatically; it saves time only when there is a lot of data, or when not all of it is needed. Both "generators are faster" and "generators always save memory" claim more than was measured.

That "always" is a word too far is shown by the same tool. A run of bench/iterators/memory_when_not.py takes three cases in a row:

what is comparedlist peakgenerator peak
a million COMPUTED values (the case above)38.57 MiB464 B
a sum over an ALREADY existing list48 B400 B
the result is needed in full anyway38.57 MiB38.57 MiB

In the second row the generator saves nothing and costs 352 bytes more: the list is already occupied before the measurement, and laziness does not undo it. In the third there is no win at all — list(i * i for i in range(N)) holds the same million values a comprehension does. And the fourth block of that run shows the other side: a suspended generator is a live frame with every reference it holds, and a hundred such generators with a buffer inside took 76.32 MiB.

The win comes not from laziness itself but from the alternative occupying memory. No alternative, no win.

Deeper: why the small-data loss is structural

implementation detail · CPython 3.12Inlining comprehensions (PEP 709) is implementation. It is why the sign of the small-data difference became stable.

The reason is visible without a stopwatch. In 3.12 comprehensions were inlined into the calling code (PEP 709): "Dictionary, list, and set comprehensions are now inlined, rather than creating a new single-use function object for each execution". Generator expressions were not part of that change — and that can be checked directly:

<listcomp> in the codeMAKE_FUNCTIONstack depth inside
list comprehension, 3.11yesyes4
list comprehension, 3.12–3.14nono3
generator expression, 3.11–3.14yes (<genexpr>)yes4

The list comprehension stopped being a separate function; the generator expression stayed one, in all four versions. Hence the extra frame, and hence the extra nanoseconds on short sequences.

Timings are never compared across versions in this lesson, and the reason is in the builds. 3.11 and 3.12 are built with GCC 13.3.0; 3.13 and 3.14 use Clang 20.1.4, and 3.14 additionally has --with-tail-call-interp, which 3.13 does not and cannot have. The difference between builds swamps the difference between versions. Only generator-against-list within a single run is comparable — which is what the tables above do.

Deeper: StopIteration inside a generator

The classic mistake the language eventually outlawed:

PYTHON
def broken(it):
    while True:
        yield next(it)      # when it runs out, next raises StopIteration
 
list(broken(iter([1, 2])))

Before Python 3.7 this silently produced [1, 2]: a StopIteration escaping the body is indistinguishable from the generator finishing normally. A bug deep in a pipeline turned into "the data just ended early".

From 3.7 on it is an exception:

RuntimeError: generator raised StopIteration

and its __cause__ is the original StopIteration, so the real reason is not lost. Checked on 3.11, 3.12, 3.13 and 3.14 — identical in all four.

There are two boundaries here, and both are recorded in the standard library — you can read them without leaving the interpreter:

PYTHON
import __future__
__future__.generator_stop
# _Feature((3, 5, 0, 'beta', 1), (3, 7, 0, 'alpha', 0), 8388608)

Optional from 3.5.0b1, mandatory from 3.7.0a0.

The correct form catches and returns:

PYTHON
def fixed(it):
    while True:
        try:
            yield next(it)
        except StopIteration:
            return

Deeper: a generator as a consumer — send, throw, close

PEP 342 made yield an expression, and the generator stopped being only a source. send(value) resumes it with the value becoming the result of the yield; throw raises an exception at the point of suspension; close raises GeneratorExit there.

Of the three, close stands apart: it is the only one that is about cleanup rather than about pushing data in:

PYTHON
def g():
    try:
        yield 1
    except GeneratorExit:
        return "cleaned up"
 
gen = g(); next(gen)
gen.close()
versionwhat close() returns
3.11, 3.12None
3.13, 3.14'cleaned up'

The change is stated in the reference outright: "Changed in version 3.13: If a generator returns a value upon being closed, the value is returned by close()". Measured on all four versions.

Deeper: yield from is not sugar over a loop

PEP 380 added delegation. The difference from a hand-written loop is not brevity: send, throw and the subgenerator's return value all pass through yield from, and none of them passes through for x in inner(): yield x.

yield from is not always the faster of the two. Walking a thousand elements through one layer — read the table by rows: within a row the loop and the delegation were measured in one run of one interpreter, while across rows sit different builds, whose contribution cannot be subtracted.

hand-written loopyield from
3.11.1545.6 µs47.2 µs
3.13.737.3 µs33.8 µs
3.14.736.3 µs32.2 µs

On 3.11 delegation is not faster but slightly slower than the hand-written loop — the sign there is the opposite one. So "yield from is always faster" is simply untrue; choose it for the protocol it forwards, not for the nanoseconds.

Deeper: the same protocol through await

Asynchronous iteration is the same protocol under different names, with one addition: every step may yield control.

Must return an awaitable resulting in a next value of the iterator. Should raise a StopAsyncIteration error when the iteration is over.

The data model — __anext__

The correspondence is one to one: __aiter__ for __iter__, __anext__ for __next__, StopAsyncIteration for StopIteration. The "its own iterator" marker works the same way (bench/iteration/async_iteration.py, identical output on 3.11–3.14):

1) a.__aiter__() is a          : True
   AsyncRange has __iter__     : False
   AsyncRange has __next__     : False
3) await it.__anext__() -> 1
   await it.__anext__() -> StopAsyncIteration (not StopIteration)

One syntactic subtlety: __aiter__ is an ordinary def, not an async def. Returning an awaitable from it was allowed until 3.7, when that support was removed; since then it must return the asynchronous iterator itself, or TypeError.

Two bridges break, and it is worth knowing in advance:

4) an ordinary for over AsyncRange -> TypeError: 'AsyncRange' object is not iterable
   list(async generator) -> TypeError: 'async_generator' object is not iterable

So list, sum, sorted, min and everything else that takes an iterable will not accept an asynchronous source. Collecting one into a list is possible only with an asynchronous comprehension: [v async for v in ...].

Being single-use is inherited whole — a second pass over an asynchronous generator is empty, exactly as with an ordinary one.

And here is what the synchronous one does not have:

7) interleaving the consumer with a background task:
   ['bg-0', 'got-1', 'bg-1', 'got-2', 'bg-2', 'got-3', 'bg-3']

8) leaving an async for by break: finally has not run yet
   after await g2.aclose(): ['finally ran']

Point 7 is the whole reason for this: every __anext__ is a switching point. Point 8 is the price: leaving an asynchronous generator by break does not run its finally right away. The cleanup has to be awaited explicitly — await g.aclose(), or the event loop's asynchronous finaliser. Exactly the class of problem that "a task nobody references" is, in the async lesson.

Deeper: version history

VersionChangeWhat changed for your code
2.1 / 2.2PEP 234 introduces the iteration protocol (__iter__ / __next__); PEP 255 introduces generator functions and yield.The mechanism appears
2.5PEP 342: yield becomes an expression, and send, throw and close arrive.A generator became a consumer
3.3PEP 380: yield from. Delegation forwards not only values but send, throw and the subgenerator's return value.Delegation, whole
3.5PEP 479 becomes available as from __future__ import generator_stop — per the record in the __future__ module, from 3.5.0b1.Opt-in by hand
3.7A StopIteration escaping a generator body becomes a RuntimeError. Mandatory from 3.7.0a0.A silent bug became loud
3.12PEP 709 inlines list, dict and set comprehensions. Generator expressions stay a separate function — visible in MAKE_FUNCTION and in stack depth.Comprehensions got cheaper, generators did not
3.13close() returns a value if the generator returned one on being closed. Before that it returned None.Cleanup can report back

How to answer in an interview

The short answer: an iterable can hand you an iterator (__iter__); an iterator can hand you the next item (__next__) and must hand back itself from __iter__. One method of difference, and one difference in behaviour: a list can be walked twice, an iterator cannot. A generator is an iterator written as a function, and calling a generator function does not run its body — it builds an object and returns.

That is enough to answer correctly. Beyond it is what you add if the interviewer digs.

If the interviewer digs deeper

What separates a good answer: not stopping at "generators save memory" but naming both sides of the trade and the limit of the win. In memory the win appears when the alternative materialises every result: on a million squares the list takes 38.57 MiB against 0.5 KiB for the generator — while summing over an already existing list saves nothing at all, 48 B against 400 B for the generator expression. In time the sign depends on the size: on ten elements creating and summing through a generator costs MORE than through a list comprehension — 375 ns against 238 — and the reason is structural: comprehensions have been inlined since 3.12, generator expressions have not.

Next they ask

Next they ask

The generator is exhausted. Can it be walked a second time?

Short answer

No, and this is the bug that does not raise: a second pass simply yields nothing — not an exception, an empty result. Part of the standard library behaves the same way, and a second pass over those is silently empty too.

Next they ask

A generator saves memory. So it is always better than a list?

Short answer

No, and there are two caveats. First, the memory win itself appears only when the alternative materialises every result; over an already existing list a generator expression saves nothing and costs slightly more. Second, a win in memory is not a win in time: on small data a generator loses structurally, and the lesson shows that by measurement rather than argument.

The choice between them is a choice between memory and time, not between modern and outdated.

Common misconceptions

Claim

“An iterable and an iterator are the same thing.”

Actually

Two lines settle it: iter(xs) is xs is False for a list and True for an iterator. A list is a source of iterators, so it can be walked any number of times; an iterator is the stream itself, and there is one. That is why list(it) a second time returns [] while list(xs) returns the same three items.

Claim

“If a generator is exhausted I will find out — there will be an exception.”

Actually

There will not. The glossary says it plainly: an exhausted iterator ends up "making it appear like an empty container". A two-pass function returned {'total': 19, 'negative': 2} for a list and {'total': 0, 'negative': 2} for a generator. Zero instead of nineteen, with nothing in the log.

Claim

“Calling a generator function runs its body.”

Actually

It builds an object and returns. Checked with a log: after obj = h() it is empty, and the entry appears only after the first next(obj). The practical consequence: a function with yield that validates arguments at the top of its body will not validate them at call time — the exception arrives later, from a different place in the stack.

Claim

“Generators are faster than lists.”

Actually

Faster on large volumes, slower on small ones. On a million elements: 45.6 ms for the generator against 59.1 for the list (3.13.7) — building the list costs more than not building it. On ten elements: 375 ns against 238 — every next resumes a suspended function, and over a short walk that does not pay off. On memory a generator wins where the alternative materialises the whole set: 0.5 KiB against 38.57 MiB on a million. Where there is nothing to materialise it does not win at all: a sum over an already existing list costs 48 B, the same sum through a generator expression 400 B.

Claim

“Comprehensions were inlined in 3.12, so generator expressions were too.”

Actually

PEP 709 does not touch them, and you can see it without benchmarks. Since 3.12 a list comprehension has neither a <listcomp> code object nor a MAKE_FUNCTION, and the stack depth inside it is 3. A generator expression still has <genexpr> and MAKE_FUNCTION in all four versions, and a stack depth of 4. The separate function and the separate frame are still there.

Claim

“Calling next() on another iterator inside a generator is a normal way to read it.”

Actually

Since 3.7 it is a RuntimeError: generator raised StopIteration. Before 3.7 it was worse: a StopIteration from the body was indistinguishable from finishing normally, and the pipeline silently ended early. Both boundaries are recorded in the standard library: __future__.generator_stop is optional from 3.5.0b1 and mandatory from 3.7.0a0. The correct form wraps next in try/except StopIteration and exits with return.

Claim

yield from is just a shorter loop, and it is faster.”

Actually

Neither. PEP 380 forwards send, throw and the subgenerator's return value through the delegation; for x in inner(): yield x forwards none of them. And on timing, 3.11.15 shows no difference at all (44.1 µs against 44.0); the advantage shows up only on other builds. Choose it for the protocol, not for the speed.

Claim

“Checking x in generator is safe — it is only a check.”

Actually

The check consumes. 3 in (i for i in range(5)) returns True, and afterwards only [4] is left: the operator walked to the match and ate everything on the way. For the same reason a generator has no len()TypeError: object of type 'generator' has no len() — because finding the length would mean exhausting the stream.

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

The same iterator is summed twice, and then a list is built from it. What does this code print?
nums = iter([1, 2, 3])
print(sum(nums))
print(sum(nums))
print(list(nums))

Practice · estimate

Ten elements: create and sum the squares, through a generator and through a list comprehension. How many times MORE expensive is the generator?
times

Knowledge check

Question 1 of 6

A function takes a sequence, sums it, and separately collects the negative values — two passes. It is handed a generator expression. What happens?

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Sources & further reading

8 SOURCES

  1. Python glossary — iterable, iterator, generator, generator expressionOfficial documentation. The definitions the whole lesson rests on. On exhaustion, verbatim: «At this point, the iterator object is exhausted and any further calls to its __next__() method just raise StopIteration again». And on a second pass, the sentence that describes the silent bug itself: «Attempting this with an iterator will just return the same exhausted iterator object used in the previous iteration pass, making it appear like an empty container».https://docs.python.org/3.13/glossary.html
  2. The language reference — yield expressions and generator-iterator methodsOfficial documentation. «Using a yield expression in a function's body causes that function to be a generator function». Also the definitions of send, throw and close, and the 3.13 note: «If a generator returns a value upon being closed, the value is returned by close()».https://docs.python.org/3.13/reference/expressions.html
  3. PEP 234 — IteratorsPEP. The document that put the iteration protocol into the language (Python 2.1): the __iter__ / __next__ pair, and the requirement that an iterator return itself from __iter__.https://peps.python.org/pep-0234/
  4. PEP 255 — Simple GeneratorsPEP. Introduces yield and generator functions (Python 2.2), including the property everything else follows from: calling a generator function does not execute its body.https://peps.python.org/pep-0255/
  5. PEP 342 — Coroutines via Enhanced GeneratorsPEP. Python 2.5. Turns yield from a statement into an expression and adds send, throw and close — which is what makes a generator a consumer as well as a producer.https://peps.python.org/pep-0342/
  6. PEP 380 — Syntax for Delegating to a SubgeneratorPEP. Python 3.3, `yield from`. Delegation is not a shorter loop: send, throw and the subgenerator's return value all travel through it.https://peps.python.org/pep-0380/
  7. What's New in Python 3.12 — PEP 709, comprehension inliningOfficial documentation. «Dictionary, list, and set comprehensions are now inlined, rather than creating a new single-use function object for each execution of the comprehension», a speedup of «up to two times». Generator expressions are NOT part of that change — which is checked here with a disassembler and a stack depth, not only with a quotation.https://docs.python.org/3/whatsnew/3.12.html
  8. The __future__ module — the generator_stop recordCPython source code. Both PEP 479 boundaries are recorded in the standard library itself: `_Feature((3, 5, 0, 'beta', 1), (3, 7, 0, 'alpha', 0), 8388608)` — optional from 3.5.0b1, mandatory from 3.7.0a0. The first tuple is the version in which the behaviour became available through `from __future__ import generator_stop`, the second the version in which it turned itself on. Checked on 3.11, 3.12, 3.13 and 3.14: the record is identical in all four. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/__future__.py