Create your own
Lesson illustration

Predicting Generator Print Order and Completion Across `next()` Calls

Hello. This course builds a precise execution model for code that pauses, preserves state, and later resumes—first with generators, then closures and context managers, and finally asyncio tasks and task-local context. The important habit throughout will be to trace which object is currently executing, where it pauses, and what action resumes it.

Today’s outcome is deliberately narrow: given a generator and several next() calls, you should be able to predict every printed line, identify where execution is paused, and recognize when the generator is exhausted.


A generator function creates a paused computation

A function containing yield is a generator function:

def numbers():
    yield 1
    yield 2

Calling a regular function begins running its body immediately. Calling a generator function does not run its body. Instead, it creates and returns a generator object.

gen = numbers()

At this point:

  • gen exists.
  • Neither yield 1 nor yield 2 has executed.
  • No code inside numbers() has run yet.

A generator object is an iterator with a preserved execution state. It knows its local variables and the exact source location from which it should continue. Calling next(gen) tells Python: run this generator until it either reaches its next yield or finishes.

The first next() starts execution at the beginning. Later calls resume immediately after the previous yield.

Python Generators - Visually Explained

Watch “Python Generators - Visually Explained” from Visually Explained for a compact visual model of generator creation, suspension, and resumption.

Watch yield versus return to establish the essential distinction: return completes a function, whereas yield suspends it. Then watch creation and next, paying particular attention to the fact that calling a generator function creates an idle object, while next() is what starts or resumes its body.

The Python documentation calls generators “resumable functions,” which is the right mental model for the rest of this course.

Functional Programming HOWTO

Read the “Generators” section of Python’s Functional Programming HOWTO. It gives the language-level model behind the traces you will perform: creation produces an iterator, yield preserves state, and exhaustion raises StopIteration.

In the “Generators” section, read the explanation of resumable functions. Then follow the generate_ints(3) interactive session immediately below it, including its final failed next() call. Finish with the completion rule: reaching the end of the body, or executing return, ends the generator permanently.


yield pauses; it does not print

A frequent tracing mistake is to treat yield value as if it printed value. It does not. It hands a value to the caller of next().

In this statement:

print(next(gen))

Python must evaluate the argument to print() first. Therefore:

  1. next(gen) runs the generator until a yield or completion.
  2. The yielded value becomes the result of the next(gen) expression.
  3. The outer print() prints that returned value.

This nesting is why prints inside a generator appear before the line that prints the result of next().

Consider the following trace. Before running it, write down the output in order, including the last two lines.

def stages():
    print("G: entered")
    value = 10

    print("G: before first yield")
    yield value

    print(f"G: resumed; value is {value}")
    value += 5
    yield value

    print("G: end")


print("M: before creation")
g = stages()
print("M: after creation")

print(f"M: first result is {next(g)}")
print(f"M: second result is {next(g)}")

try:
    print(f"M: third result is {next(g)}")
except StopIteration:
    print("M: exhausted")

The output is:

M: before creation
M: after creation
G: entered
G: before first yield
M: first result is 10
G: resumed; value is 10
M: second result is 15
G: end
M: exhausted

The key is to separate the generator’s state from the main code’s state.

Main-code actionWhat the generator doesGenerator state afterward
g = stages()Nothing in the body runsCreated, not started
First next(g)Runs through yield valuePaused at first yield, with value == 10
Second next(g)Resumes after first yield, increments value, reaches second yieldPaused at second yield, with value == 15
Third next(g)Resumes after second yield, prints G: end, reaches the function endFinished; raises StopIteration

Two details are worth making explicit:

  • After the second call, the line print("G: end") has not run. The generator is paused at the second yield.
  • On the third call, next(g) raises StopIteration rather than returning a value. Because evaluating the f-string fails, the outer print("M: third result ...") never runs.

The local variable value survives across both suspension points. Python retained the generator frame: local bindings, instruction position, and relevant call state.


A reliable paper-tracing method

When you see a generator plus next() calls, do not mentally execute the whole generator at once. Use this routine:

  1. Find generator functions. A function containing yield is a generator function.
  2. Mark creation separately. g = generator_function() creates a generator object; its body has not started.
  3. For each next(g), enter the generator at its current pause point.
  4. Run only until the next yield or the end of the function.
  5. If a value is yielded, substitute that value into the enclosing expression. Only then finish the caller’s statement.
  6. If the generator reaches return or its end, record StopIteration.

You can annotate a source listing with a small state note after each call:

created        body has not run
after next #1  paused at yield "A"
after next #2  paused at yield "B"
after next #3  finished

This is more dependable than trying to infer the output from indentation alone.


Exhaustion is final for that generator object

A finite generator has one forward-only lifetime. Once it finishes, it cannot be restarted.

def one_item():
    print("G: start")
    yield "item"
    print("G: cleanup at natural end")


g = one_item()

print(next(g))
print("between calls")

try:
    next(g)
except StopIteration:
    print("finished")

try:
    next(g)
except StopIteration:
    print("still finished")

Its output is:

G: start
item
between calls
G: cleanup at natural end
finished
still finished

The second next(g) resumes after yield "item", runs the final print, then falls off the end of the function. Falling off the end means the generator is complete and next(g) raises StopIteration.

The third next(g) does not enter one_item() again. It immediately raises StopIteration, because this particular generator object is already exhausted.

To start over, create a new generator object:

fresh_g = one_item()

fresh_g has a separate execution state and starts at the beginning on its first next().

A return statement also completes a generator. In a generator, return is not another yielded item:

def short():
    yield "available"
    return

After yielding "available", the next advancement completes the generator and raises StopIteration.


for loops repeatedly call next() for you

Manual next() is ideal for learning because each advancement is visible. Production code often uses a for loop, which repeatedly advances the generator and handles StopIteration internally.

Trace this code before executing it:

def labels():
    print("G: start")

    for label in ("A", "B", "C"):
        print(f"G: preparing {label}")
        yield label

    print("G: finish")


g = labels()

print(f"manual: {next(g)}")

for item in g:
    print(f"loop: {item}")

print("main: after loop")

Expected output:

G: start
G: preparing A
manual: A
G: preparing B
loop: B
G: preparing C
loop: C
G: finish
main: after loop

The for loop does not restart g at "A". It continues from its current suspended position, so it gets "B" and "C" only.

There is also an invisible final advancement: after receiving "C", the loop calls next(g) once more to check whether another item exists. That call resumes the generator, prints G: finish, and raises StopIteration. The loop catches that exception and ends normally.

Conceptually, the loop behaves roughly like this:

while True:
    try:
        item = next(g)
    except StopIteration:
        break
    print(f"loop: {item}")

This also explains why a partially consumed generator stays partially consumed:

g = labels()
next(g)       # consumes "A"
print(list(g))  # gets ["B", "C"]

And after list(g) has consumed the remaining values, a further list(g) produces [].


What to retain from this lesson

A generator is not a function body that runs at creation time. It is a stateful iterator created by calling a function that contains yield.

  • Calling the generator function creates an unstarted generator object.
  • next(generator) starts or resumes it.
  • A yield gives a value to the next() caller and pauses execution immediately at that point.
  • Local variables and the current source position survive while paused.
  • On the next advancement, execution resumes just after the previous yield.
  • Reaching return or the end of the function completes it; next() then raises StopIteration.
  • A for loop repeatedly calls next() and handles StopIteration for you.

Next, we will use the same “preserved state after an outer call returns” idea for closures: functions that retain and update captured variables without using a class.

Can't find a good explanation? Sign up and we'll make it for you

Sign up