DEV Community

Cover image for Python Generators Part 2: How They Actually Work (The Magic Revealed)
prashant chouksey
prashant chouksey

Posted on • Edited on

Python Generators Part 2: How They Actually Work (The Magic Revealed)

Understanding the pause button: frames, state, and bidirectional communication

In Part 1, you learned:

  • โœ… Generators produce values one at a time (lazy)
  • โœ… The yield keyword pauses the function
  • โœ… Generators use constant memory (112 bytes)
  • โœ… Use for loops or next() to get values

If you haven't reviewed Part 1 yet, please refer to it here.

Now let's answer the big question: How does Python remember where it paused?


The Big Question: Where is the State Saved?

When a generator pauses at yield, it needs to remember:

  • ๐Ÿ“ Where it was in the code (which line?)
  • ๐Ÿงฎ All local variables (their current values)
  • ๐Ÿ“Š The call stack (in case there were nested function calls)

Where does Python store all this?

Answer: In a special object called a Generator Frame.


What Happens When You Call a Generator Function

Let's trace through this step-by-step:

def counter(start, end):
    current = start
    while current < end:
        value = current ** 2
        yield value
        current += 1

# Step 1: Call the function
gen = counter(0, 3)
Enter fullscreen mode Exit fullscreen mode

What just happened?

โŒ WRONG: The function ran and calculated squares

โœ… CORRECT: Python created a "Generator Object" and did NOTHING else

The Generator Object

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚    Generator Object (112 bytes)         โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  gi_frame: <Frame object>               โ”‚  โ† WHERE STATE IS SAVED
โ”‚  gi_code: <counter function bytecode>   โ”‚  โ† THE CODE TO RUN
โ”‚  gi_running: False                       โ”‚  โ† IS IT RUNNING NOW?
โ”‚  gi_yieldfrom: None                      โ”‚  โ† For yield from
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

The most important part: gi_frame - this is where all the magic happens!


Inside the Frame: Where Variables Live

The frame (gi_frame) contains:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚           Generator Frame                โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  f_locals: {}                            โ”‚  โ† Local variables
โ”‚  f_lasti: 0                              โ”‚  โ† Instruction pointer
โ”‚  f_valuestack: []                        โ”‚  โ† Expression stack
โ”‚  f_back: None                            โ”‚  โ† Caller's frame
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

Let's see what happens when we call next():

gen = counter(0, 3)

# FIRST next()
print(next(gen))  # 0
Enter fullscreen mode Exit fullscreen mode

Frame BEFORE first next():

f_locals: {}  (empty - hasn't run yet!)
f_lasti: 0    (start of function)
Enter fullscreen mode Exit fullscreen mode

Frame AFTER first next() (paused at yield):

f_locals: {
    'start': 0,
    'end': 3,
    'current': 1,     โ† Updated!
    'value': 0
}
f_lasti: 58    โ† Points to instruction AFTER yield
Enter fullscreen mode Exit fullscreen mode

Frame AFTER second next():

f_locals: {
    'start': 0,
    'end': 3,
    'current': 2,     โ† Updated again!
    'value': 1
}
f_lasti: 58    โ† Same yield point
Enter fullscreen mode Exit fullscreen mode

See the magic? Python saves everything and resumes exactly where it left off!


The Execution Lifecycle (States)

A generator goes through different states:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ GEN_CREATED  โ”‚  โ† Just created, hasn't run yet
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚
       โ”‚ next() or send()
       โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ GEN_RUNNING  โ”‚  โ† Currently executing code
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚
       โ”‚ hit yield
       โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚GEN_SUSPENDED โ”‚  โ† โธ๏ธ Paused, state saved
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚
       โ”œโ”€โ”€โ”€โ”€ next() โ”€โ”€โ”€โ”€โ–ถ (loop back to GEN_RUNNING)
       โ”‚
       โ””โ”€โ”€โ”€โ”€ StopIteration or close() โ”€โ”€โ”€โ”€โ–ถ GEN_CLOSED
Enter fullscreen mode Exit fullscreen mode

The Four Powerful Methods

Generators aren't just for reading values! They support four methods for advanced control:

1. next() - Get Next Value โ–ถ๏ธ

You already know this one!

def simple_gen():
    yield 1
    yield 2
    yield 3

gen = simple_gen()
print(next(gen))  # 1
print(next(gen))  # 2
print(next(gen))  # 3
print(next(gen))  # StopIteration Error!
Enter fullscreen mode Exit fullscreen mode

2. send(value) - Send Data INTO the Generator ๐Ÿ“จ

This is where it gets interesting! You can send values into a generator while it's running.

def echo():
    while True:
        # The yield can RECEIVE a value!
        received = yield
        print(f"You sent: {received}")

gen = echo()
next(gen)  # โš ๏ธ MUST prime first! (advance to first yield)

gen.send("Hello")   # You sent: Hello
gen.send("World")   # You sent: World
gen.send(42)        # You sent: 42
Enter fullscreen mode Exit fullscreen mode

How does send() work?

Generator code:    received = yield
                              โ†‘
                              โ”‚
gen.send("Hello") โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The yield expression RECEIVES the value "Hello"
Then: received = "Hello"
Enter fullscreen mode Exit fullscreen mode

Real Example: Running Average Calculator

def running_average():
    total = 0
    count = 0
    average = None

    while True:
        # Yield the current average, receive the next number
        number = yield average

        if number is None:
            break

        total += number
        count += 1
        average = total / count

# Usage
avg = running_average()
next(avg)  # Prime (get to first yield)

print(avg.send(10))  # 10.0  (running avg: 10)
print(avg.send(20))  # 15.0  (running avg: 15)
print(avg.send(30))  # 20.0  (running avg: 20)
print(avg.send(40))  # 25.0  (running avg: 25)
Enter fullscreen mode Exit fullscreen mode

Step-by-step flow:

1. next(avg)
   โ”œโ”€ Start function
   โ”œโ”€ Set total=0, count=0, average=None
   โ”œโ”€ Hit: number = yield average
   โ”œโ”€ Yield None (first average)
   โ””โ”€ โธ๏ธ PAUSE

2. avg.send(10)
   โ”œโ”€ Resume: number = yield average
   โ”œโ”€ number receives 10
   โ”œโ”€ total = 10, count = 1
   โ”œโ”€ average = 10.0
   โ”œโ”€ Loop back to: number = yield average
   โ”œโ”€ Yield 10.0
   โ””โ”€ โธ๏ธ PAUSE

3. avg.send(20)
   โ”œโ”€ Resume: number = yield average
   โ”œโ”€ number receives 20
   โ”œโ”€ total = 30, count = 2
   โ”œโ”€ average = 15.0
   โ”œโ”€ Yield 15.0
   โ””โ”€ โธ๏ธ PAUSE
Enter fullscreen mode Exit fullscreen mode

3. close() - Stop the Generator and Cleanup ๐Ÿ›‘

When you're done with a generator, you should close it to free resources:

def file_reader(filename):
    print(f"Opening {filename}")
    f = open(filename, 'r')

    try:
        for line in f:
            yield line.strip()
    except GeneratorExit:
        # This runs when close() is called!
        print("Closing file...")
        f.close()
        raise  # Must re-raise or return

# Usage
reader = file_reader('data.txt')
print(next(reader))  # First line
print(next(reader))  # Second line

reader.close()  # Triggers cleanup!
# Output: Closing file...
Enter fullscreen mode Exit fullscreen mode

What close() does:

  1. Raises GeneratorExit exception inside the generator
  2. Generator's except GeneratorExit block runs
  3. Cleanup code executes
  4. Generator marked as GEN_CLOSED

4. throw(exception) - Inject Errors ๐Ÿ’ฅ

You can raise an exception inside the generator from outside:

def error_handler():
    while True:
        try:
            value = yield
            print(f"Processing: {value}")
        except ValueError as e:
            print(f"Caught error: {e}")
            # Generator continues running!

gen = error_handler()
next(gen)  # Prime

gen.send(10)  # Processing: 10
gen.send(20)  # Processing: 20

# Inject an error from outside!
gen.throw(ValueError, "Bad value!")
# Output: Caught error: Bad value!

gen.send(30)  # Processing: 30 (still works!)
Enter fullscreen mode Exit fullscreen mode

Use case: Error handling in data pipelines

def data_ ():
    while True:
        try:
            data = yield
            if data < 0:
                raise ValueError("Negative values not allowed")
            result = process(data)
            print(f"Result: {result}")
        except ValueError as e:
            print(f"Skipping bad data: {e}")

processor = data_processor()
next(processor)

processor.send(10)   # Result: ...
processor.send(-5)   # Skipping bad data: Negative values not allowed
processor.send(20)   # Result: ... (continues!)
Enter fullscreen mode Exit fullscreen mode

Method Comparison Table

Method What It Does Returns Use When
next(gen) Get next value Next yielded value Basic iteration
gen.send(val) Send value into generator Next yielded value Two-way communication
gen.throw(exc) Raise exception inside Next yield (if handled) Error injection
gen.close() Stop generator None Cleanup resources

Advanced: yield from - Generator Delegation

When you have generators calling other generators, yield from makes it seamless:

def generator1():
    yield 1
    yield 2

def generator2():
    yield 3
    yield 4

# Without yield from
def combined_old():
    for value in generator1():
        yield value
    for value in generator2():
        yield value

# With yield from (cleaner!)
def combined_new():
    yield from generator1()
    yield from generator2()

print(list(combined_new()))  # [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

Why is yield from better?

It transparently forwards all operations:

  • โœ… next() calls
  • โœ… send() values
  • โœ… throw() exceptions
  • โœ… close() signals

Practical Example: Multi-File Log Reader

def read_file(filename):
    """Read one file"""
    print(f"Opening {filename}")
    with open(filename) as f:
        for line in f:
            yield line.strip()
    print(f"Closed {filename}")

def read_multiple_files(filenames):
    """Read multiple files sequentially"""
    for filename in filenames:
        yield from read_file(filename)

# Usage
files = ['log1.txt', 'log2.txt', 'log3.txt']
for line in read_multiple_files(files):
    if 'ERROR' in line:
        print(line)
Enter fullscreen mode Exit fullscreen mode

Output:

Opening log1.txt
... errors from log1 ...
Closed log1.txt
Opening log2.txt
... errors from log2 ...
Closed log2.txt
Opening log3.txt
... errors from log3 ...
Closed log3.txt
Enter fullscreen mode Exit fullscreen mode

Understanding the Pause and Resume

Let's visualize exactly what happens:

def demo():
    print("A")
    x = yield 1
    print(f"B, x={x}")
    y = yield 2
    print(f"C, y={y}")

gen = demo()
Enter fullscreen mode Exit fullscreen mode

Execution trace:

Call: gen = demo()
  โžœ Creates generator object
  โžœ NO CODE RUNS
  โžœ State: GEN_CREATED

Call: next(gen)
  โžœ Start execution
  โžœ Execute: print("A")          [Output: "A"]
  โžœ Execute: yield 1
  โžœ Return: 1
  โžœ Save state (x not assigned yet!)
  โžœ State: GEN_SUSPENDED
  โžœ Returns: 1

Call: gen.send(100)
  โžœ Resume execution
  โžœ x = 100 (yield receives sent value)
  โžœ Execute: print(f"B, x={x}")  [Output: "B, x=100"]
  โžœ Execute: yield 2
  โžœ Return: 2
  โžœ Save state (y not assigned yet!)
  โžœ State: GEN_SUSPENDED
  โžœ Returns: 2

Call: gen.send(200)
  โžœ Resume execution
  โžœ y = 200
  โžœ Execute: print(f"C, y={y}")  [Output: "C, y=200"]
  โžœ End of function
  โžœ Raise: StopIteration
  โžœ State: GEN_CLOSED
Enter fullscreen mode Exit fullscreen mode

Summary: What You Learned

๐ŸŽฏ Key Concepts:

  1. Generator state is saved in a "frame" (gi_frame)
  2. Frames store local variables and instruction pointer
  3. send() allows bidirectional communication
  4. close() triggers cleanup with GeneratorExit
  5. throw() injects exceptions for error handling
  6. yield from delegates to sub-generators

๐Ÿ”‘ Remember:

  • Generators go through states: CREATED โ†’ RUNNING โ†’ SUSPENDED โ†’ CLOSED
  • The frame remembers everything between yields
  • You can send data into generators, not just get data out

Happy coding! ๐ŸŽ‰

Top comments (0)