Skip to main content

Generators and yield - Suspended Execution at Engineering Depth

Reading time: ~35 minutes | Level: Intermediate → Engineering

Before reading further, predict the exact output of this program, line by line:

def counter(n):
print("starting")
for i in range(n):
print(f"before yield {i}")
yield i
print(f"after yield {i}")

gen = counter(3)
print("created")
x = next(gen)
print(f"got {x}")
y = next(gen)
print(f"got {y}")

Write out every line you expect to see, in order.

Most developers either guess that everything prints when counter(3) is called, or they know about laziness but get the interleaving wrong. The actual output:

created
starting
before yield 0
got 0
after yield 0
before yield 1
got 1

The function body does not execute when counter(3) is called. It executes when next() is called, runs until it hits yield, suspends, and returns control to the caller. The next next() call resumes from exactly where it left off - including the print after the yield.

Generators are not just lazy lists. They are suspended coroutines - functions that can be paused and resumed mid-execution, preserving their entire local state between calls.

What You Will Learn

  • What yield actually does to a function's execution frame
  • The generator object and its __iter__/__next__ protocol
  • The generator state machine: Created → Running → Suspended → Closed
  • The send() protocol: sending values back into a running generator
  • yield from: delegating to sub-generators and transparent passthrough
  • throw() and close(): injecting exceptions and terminating generators
  • StopIteration and the return value of a generator
  • Memory efficiency: why generators change what problems are solvable
  • Generator expressions vs generator functions
  • Real-world: Django querysets, asyncio, and data pipelines

Prerequisites

  • Lesson 01: Lambda Expressions
  • Lesson 02: map, filter, reduce (particularly lazy iterators)
  • Python Foundation: functions, for loops, iterables

Part 1 - What yield Does

A Regular Function vs a Generator Function

A regular function runs to completion and returns a single value:

def regular():
print("running")
return 42

result = regular() # prints "running", returns 42
print(result) # 42

A generator function contains yield. The presence of yield transforms the function's behaviour completely:

def generator():
print("running")
yield 42

gen = generator() # prints nothing - body does not execute yet
print(gen) # <generator object generator at 0x...>
result = next(gen) # prints "running", suspends at yield, returns 42
print(result) # 42

The critical difference: generator() does not execute the function body. It creates and returns a generator object. The body only begins executing when next() is called on that object.

What the CPython Frame Contains

When a generator suspends at yield, Python saves the entire execution frame:

  • All local variables at their current values
  • The instruction pointer - exactly which bytecode instruction to resume from
  • The stack - any intermediate values in the expression evaluation stack
  • The exception state

This is not a snapshot - it is the live frame, kept alive in the generator object. When next() is called again, Python resumes from the saved instruction pointer with all locals intact.

def demonstrate_locals():
x = 10
print(f"x before yield: {x}")
yield x # suspends here; x = 10 is preserved in frame
x = x + 5
print(f"x after resume: {x}")
yield x

gen = demonstrate_locals()
print(next(gen)) # prints "x before yield: 10", yields 10
print(next(gen)) # prints "x after resume: 15", yields 15

Part 2 - The Generator State Machine

A generator object moves through four states:

import inspect

def my_gen():
yield 1
yield 2

gen = my_gen()

print(inspect.getgeneratorstate(gen)) # GEN_CREATED

next(gen)
print(inspect.getgeneratorstate(gen)) # GEN_SUSPENDED

next(gen)
print(inspect.getgeneratorstate(gen)) # GEN_SUSPENDED

try:
next(gen)
except StopIteration:
pass

print(inspect.getgeneratorstate(gen)) # GEN_CLOSED

StopIteration and the return Value

When a generator function's body completes (either by reaching the end or by an explicit return), Python raises StopIteration. If there is an explicit return with a value, that value is attached to the StopIteration exception as its .value attribute:

def gen_with_return():
yield 1
yield 2
return "done" # not yielded - becomes StopIteration.value

gen = gen_with_return()
print(next(gen)) # 1
print(next(gen)) # 2

try:
next(gen)
except StopIteration as e:
print(f"Stopped with value: {e.value}") # Stopped with value: done

The return value is rarely used directly in simple generators. It becomes important with yield from and the coroutine protocol.

Part 3 - The send() Protocol

Sending Values Into a Generator

next(gen) is equivalent to gen.send(None). But generators also support gen.send(value) - this resumes the generator and makes the yield expression evaluate to value:

def accumulator():
total = 0
while True:
value = yield total # yield sends total OUT; receives value IN
if value is None:
break
total += value

gen = accumulator()
next(gen) # must prime with next() or send(None) first - reaches first yield
print(gen.send(10)) # total = 10, yields 10
print(gen.send(20)) # total = 30, yields 30
print(gen.send(5)) # total = 35, yields 35

The flow of yield in this context:

  1. yield total - suspends execution and sends total to the caller
  2. .send(value) - resumes execution and makes yield total evaluate to value

This is the foundation of Python's coroutine model. A generator that both receives and sends values is a coroutine.

:::warning Always Prime a Generator Before Sending Non-None Values The first call to a generator must be next(gen) or gen.send(None). The generator must reach its first yield before it can receive a value via send().

gen = accumulator()
gen.send(10) # TypeError: can't send non-None value to a just-started generator

# Correct:
next(gen) # prime it first
gen.send(10) # now safe

This is the most common mistake when working with the send() protocol. :::

Part 4 - yield from

Basic Delegation

yield from delegates iteration to a sub-generator (or any iterable). It is not just syntactic sugar - it creates a transparent bidirectional channel:

def inner():
yield 1
yield 2
yield 3

def outer():
yield 0
yield from inner() # delegate to inner
yield 4

print(list(outer())) # [0, 1, 2, 3, 4]

Without yield from, you would need:

def outer_manual():
yield 0
for item in inner(): # works for next(), but does NOT pass send/throw through
yield item
yield 4

The manual for loop breaks the send() and throw() protocols. yield from passes them through transparently.

yield from and the return Value

yield from captures the return value of the sub-generator:

def sub():
yield 1
yield 2
return "sub_done" # StopIteration.value

def delegating():
result = yield from sub() # result receives "sub_done"
print(f"sub returned: {result}")
yield 99

gen = delegating()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # prints "sub returned: sub_done", yields 99

yield from Is the Foundation of async/await

:::note yield from Made async/await Possible async def and await in Python 3.5+ are directly built on the yield from machinery. An await expr is essentially yield from expr. The event loop sends values (results of I/O operations) back into suspended coroutines via the send() protocol.

# These are conceptually equivalent (simplified):
async def fetch(url):
result = await http_get(url)
return result

# Under the hood (pre-3.5 style):
def fetch(url):
result = yield from http_get(url)
return result

Understanding yield from is understanding how asyncio actually works. :::

Part 5 - throw() and close()

throw() - Injecting Exceptions

gen.throw(ExcType, value, traceback) raises an exception at the point where the generator is suspended:

def resilient():
try:
while True:
value = yield
print(f"received: {value}")
except ValueError as e:
print(f"caught ValueError: {e}")
yield "recovered"

gen = resilient()
next(gen) # prime
gen.send("hello") # received: hello
gen.send("world") # received: world
result = gen.throw(ValueError, "bad input")
print(result) # caught ValueError: bad input, then yields "recovered"

close() - Terminating a Generator

gen.close() throws GeneratorExit into the generator at the suspension point. The generator should not yield after this - it should clean up and return:

def managed_resource():
resource = acquire_resource()
try:
while True:
yield resource.read()
finally:
resource.release() # guaranteed to run even on close()

gen = managed_resource()
for i, data in enumerate(gen):
process(data)
if i >= 9:
gen.close() # GeneratorExit raised inside generator; finally block runs
break

:::danger Never Catch StopIteration Inside a Generator (PEP 479) In Python 3.7+, if a StopIteration propagates out of a generator, Python converts it to a RuntimeError. This prevents a subtle class of bugs where an inner iterator's exhaustion accidentally terminated the outer generator.

def buggy_gen():
items = [1, 2, 3]
it = iter(items)
while True:
try:
yield next(it)
except StopIteration:
return # OK: explicit return from generator

# Old bug (Python 3.6 and earlier): StopIteration escaping a generator
# would silently stop it. Now it's a RuntimeError.

# Correct pattern: use for loop or check differently
def correct_gen(items):
for item in items:
yield item

Always use for loops or explicit termination conditions inside generators rather than catching StopIteration. :::

Part 6 - Memory Efficiency

The Core Advantage

A generator produces values on demand and discards them once consumed. A list holds all values in memory simultaneously.

import sys

# List: all 10 million numbers in memory at once
big_list = list(range(10_000_000))
print(f"list size: {sys.getsizeof(big_list):,} bytes") # ~80,000,056 bytes

# Generator: one number at a time
big_gen = (x for x in range(10_000_000))
print(f"generator size: {sys.getsizeof(big_gen):,} bytes") # ~104 bytes

The generator is 104 bytes regardless of how many elements it will produce. The list grows linearly.

Infinite Sequences

Generators make infinite sequences possible:

def naturals():
"""Infinite sequence of natural numbers."""
n = 1
while True:
yield n
n += 1

def fibonacci():
"""Infinite Fibonacci sequence."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b

# Take the first 10 Fibonacci numbers
gen = fibonacci()
first_10 = [next(gen) for _ in range(10)]
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Lazy File Processing

def read_large_file(filepath):
"""Read a multi-GB log file one line at a time."""
with open(filepath) as f:
for line in f:
yield line.rstrip()

def parse_errors(filepath):
"""Lazy pipeline: read → filter → parse."""
lines = read_large_file(filepath)
error_lines = (line for line in lines if "ERROR" in line)
for line in error_lines:
timestamp, level, message = line.split(" ", 2)
yield {"timestamp": timestamp, "message": message}

# Processes a 10 GB file with constant memory usage
for error in parse_errors("/var/log/app.log"):
store_to_database(error)

Part 7 - Generator Expressions

Generator expressions are to generators what list comprehensions are to lists - a compact syntax for the common case:

# Generator function
def squares(n):
for i in range(n):
yield i ** 2

# Generator expression - same thing, inline
squares_gen = (i ** 2 for i in range(10))

print(type(squares_gen)) # <class 'generator'>
print(list(squares_gen)) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Generator expressions are lazy - they do not compute until iterated:

# The filter is not evaluated at construction time
large_gen = (x ** 2 for x in range(10_000_000) if x % 2 == 0)
# Instant - no computation yet

# Only computes as you consume it
first_five = list(itertools.islice(large_gen, 5))
print(first_five) # [0, 4, 16, 36, 64]

When to Use Generator Expressions vs Generator Functions

Use a generator expression when:

  • The logic is simple enough to fit on one line
  • You do not need send(), throw(), or complex control flow

Use a generator function when:

  • The generation logic is complex
  • You need multiple yield points
  • You need send() or coroutine behaviour
  • You need cleanup in a finally block

Part 8 - Real-World Patterns

Django ORM - Queryset Lazy Evaluation

Django's QuerySet uses a lazy evaluation model directly analogous to generators. The database query is not executed until you iterate the queryset, call list(), or use slicing:

# Django equivalent - no SQL executed yet
users = User.objects.filter(is_active=True).order_by("created_at")

# SQL executed here, one batch at a time with iterator()
for user in users.iterator(chunk_size=1000):
send_newsletter(user)

asyncio - Coroutines Built on Generators

Each await is a yield that suspends the coroutine and returns a future to the event loop. The event loop's send() call resumes the coroutine with the result of the I/O operation. This is the entire model.

Generator-Based Data Pipeline

import csv
import itertools

def read_csv(filepath):
"""Stage 1: read rows from CSV."""
with open(filepath, newline="") as f:
reader = csv.DictReader(f)
yield from reader

def filter_active(rows):
"""Stage 2: keep only active records."""
return (row for row in rows if row["status"] == "active")

def normalise_email(rows):
"""Stage 3: normalise email addresses."""
return (
{**row, "email": row["email"].strip().lower()}
for row in rows
)

def batch(iterable, size):
"""Stage 4: group into batches for bulk insert."""
it = iter(iterable)
while True:
chunk = list(itertools.islice(it, size))
if not chunk:
break
yield chunk

def run_pipeline(filepath):
"""Compose the full pipeline - lazy, constant memory."""
rows = read_csv(filepath)
active = filter_active(rows)
normalised = normalise_email(active)
batches = batch(normalised, 500)

for batch_rows in batches:
db.bulk_insert("users", batch_rows)
print(f"Inserted {len(batch_rows)} rows")

run_pipeline("users.csv")

This pipeline processes a file of any size - 10 rows or 10 million - with constant memory usage. Each stage is a generator that pulls from the previous stage on demand.

Part 9 - itertools and Generator Utilities

The itertools module provides production-grade generator building blocks:

import itertools

# Count forever
for i in itertools.count(start=0, step=2):
if i > 10:
break
print(i) # 0, 2, 4, 6, 8, 10

# Cycle through values
status_cycle = itertools.cycle(["pending", "processing", "done"])
for _ in range(5):
print(next(status_cycle)) # pending, processing, done, pending, processing

# Chain multiple iterables lazily
combined = itertools.chain(range(3), range(5, 8))
print(list(combined)) # [0, 1, 2, 5, 6, 7]

# Take while condition holds
nums = itertools.count(1)
small = list(itertools.takewhile(lambda x: x < 10, nums))
print(small) # [1, 2, 3, 4, 5, 6, 7, 8, 9]

:::tip Use itertools.tee() If You Need to Iterate a Generator Multiple Times A generator is single-use. itertools.tee() creates n independent iterators from one source, but note that it buffers elements - it is not free.

import itertools

gen = (x ** 2 for x in range(5))
gen1, gen2 = itertools.tee(gen, 2)

print(list(gen1)) # [0, 1, 4, 9, 16]
print(list(gen2)) # [0, 1, 4, 9, 16]

# Warning: if one iterator advances far ahead of the other,
# tee() must buffer those elements in memory.
# For large divergence, converting to list first is usually better.

:::

Engineering Checklist

Before moving on, verify you can answer these without looking:

  1. Does the generator function body execute when you call counter(3)? When does it start executing?
  2. What are the four states of a generator and what transitions exist between them?
  3. What does yield value do? What does x = yield value do differently?
  4. What must you do before calling gen.send(non_None_value) on a freshly created generator?
  5. What does yield from sub_gen do that a for loop over sub_gen does not?
  6. What happens to a StopIteration that propagates out of a generator in Python 3.7+?
  7. Why does a generator of 10 million elements use only ~100 bytes of memory?
  8. How does async/await relate to generators and yield from?

Key Takeaways

  • A function containing yield becomes a generator function. Calling it returns a generator object without executing the body
  • yield suspends execution, saves the entire frame (locals, instruction pointer, stack), returns the yielded value to the caller, and waits for the next next() call
  • Generator objects implement the iterator protocol: __iter__ returns self, __next__ resumes execution until the next yield
  • The four generator states are: Created → Running → Suspended → Closed
  • gen.send(value) resumes a suspended generator and makes the yield expression evaluate to value - the foundation of coroutines
  • Always prime a generator with next(gen) or gen.send(None) before sending non-None values
  • yield from sub_gen delegates to a sub-generator with transparent passthrough of send(), throw(), and close()
  • yield from is the direct predecessor of async/await - an await expr is essentially yield from expr
  • Never catch StopIteration inside a generator - in Python 3.7+ it becomes a RuntimeError (PEP 479)
  • Generators use constant memory regardless of how many elements they produce - they never hold the full sequence at once
  • Generator expressions (expr for x in seq if cond) are lazy generators inline; use generator functions for complex logic

Graded Practice Challenges

Level 1 - Predict the Output

Question 1: What does this print?

def gen():
yield 1
yield 2
yield 3

g = gen()
print(next(g))
print(next(g))
g.close()
try:
print(next(g))
except StopIteration:
print("stopped")
Show Answer

Output:

1
2
stopped

next(g) consumes yield 1 (prints 1) and yield 2 (prints 2). g.close() throws GeneratorExit into the generator - since the generator does not catch it, the generator terminates and moves to Closed state. The subsequent next(g) raises StopIteration because the generator is closed, caught by the try/except, printing "stopped".

Question 2: What does this print?

def countdown(n):
while n > 0:
yield n
n -= 1

gen = countdown(5)

print(sum(gen))
print(sum(gen))
Show Answer

Output:

15
0

The first sum(gen) consumes all values: 5+4+3+2+1 = 15. The generator is now exhausted (Closed state). The second sum(gen) gets an empty iterator immediately - sum([]) = 0.

Question 3: What does this print?

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

gen = annotated()
v1 = next(gen)
print(f"got {v1}")
v2 = gen.send("hello")
print(f"got {v2}")
try:
gen.send("world")
except StopIteration:
print("done")
Show Answer

Output:

A
got 1
B: received hello
got 2
C: received world
done

next(gen) (equivalent to send(None)) runs until yield 1: prints "A", yields 1. gen.send("hello") resumes; x = "hello", prints "B: received hello", runs until yield 2, yields 2. gen.send("world") resumes; y = "world", prints "C: received world", function ends, raises StopIteration.

Question 4: What does this print?

def inner():
yield "a"
yield "b"
return "inner_done"

def outer():
result = yield from inner()
print(f"inner returned: {result}")
yield "c"

print(list(outer()))
Show Answer

Output:

inner returned: inner_done
['a', 'b', 'c']

list(outer()) iterates the outer generator. yield from inner() first yields "a", then "b". When inner() finishes with return "inner_done", yield from captures that as result. The print executes, then yield "c" yields "c". The generator completes and list() collects ["a", "b", "c"].

Question 5: What does this print?

gen_expr = (x * x for x in range(5))

for val in gen_expr:
if val > 5:
break

print(list(gen_expr))
Show Answer

Output:

[9, 16]

The generator expression produces squares: 0, 1, 4, 9, 16. The loop breaks when val > 5, which happens at val = 9 (x=3). At that point the generator is suspended with x=3 consumed. list(gen_expr) resumes and consumes the remaining elements: 16 (x=4). So [9, 16]...

Wait - let me re-trace. The loop: x=0 (val=0, not > 5), x=1 (val=1, not > 5), x=2 (val=4, not > 5), x=3 (val=9, 9 > 5, break). Generator is now suspended after yielding 9 (x=3 is done). The remaining element is x=4 (val=16). So list(gen_expr) returns [16].

Actual output:

[16]

After break, the generator is suspended having yielded 9. The next value to be produced would be x=4 (16). list(gen_expr) consumes the rest: [16].

Level 2 - Debug Challenge

Find and fix all bugs. The intent is a generator that reads a CSV file lazily and yields parsed records, stopping on the first malformed row and printing an error:

import csv

def parse_records(filepath):
with open(filepath) as f:
reader = csv.DictReader(f)
for row in reader:
try:
yield {
"id": int(row["id"]),
"value": float(row["value"]),
}
except (KeyError, ValueError) as e:
print(f"Malformed row: {e}")
return # stop iteration on first error

def process():
for record in parse_records("data.csv"):
print(record)

# The code also has this usage which must be fixed:
records = list(parse_records("data.csv"))
second_run = list(parse_records("data.csv")) # user expects same result
print(len(records) == len(second_run))
Show Solution

Analysis of the generator: parse_records is correctly written. return inside a generator raises StopIteration cleanly - this is the correct way to exit early from a generator.

The bug is in the usage pattern: The developer assumes a generator can be re-used. parse_records("data.csv") creates a fresh generator each time it is called, so second_run = list(parse_records("data.csv")) is actually correct - both calls create new generators from the same file.

Where bugs can hide:

# Bug 1: storing the generator and iterating twice
gen = parse_records("data.csv")
records = list(gen) # works
second_run = list(gen) # [] - exhausted!

# Bug 2: not handling StopIteration from PEP 479
def broken_parse(filepath):
with open(filepath) as f:
reader = csv.DictReader(f)
it = iter(reader)
while True:
row = next(it) # StopIteration propagates out → RuntimeError in Python 3.7+
yield row

# Bug 3: using return value incorrectly
gen = parse_records("data.csv")
result = gen.send("data") # TypeError - generator not primed, and parse_records does not use send

Corrected robust version:

import csv

def parse_records(filepath):
"""Lazily parse CSV records; stop on first malformed row."""
with open(filepath) as f:
reader = csv.DictReader(f)
for row in reader: # for loop handles StopIteration correctly - PEP 479 safe
try:
yield {
"id": int(row["id"]),
"value": float(row["value"]),
}
except (KeyError, ValueError) as e:
print(f"Malformed row: {e}")
return # explicit return - clean StopIteration

# Correct usage: each call creates a fresh generator
records_1 = list(parse_records("data.csv"))
records_2 = list(parse_records("data.csv"))
print(len(records_1) == len(records_2)) # True - both fresh generators from same file

Level 3 - Design Challenge

Design a coroutine-based running statistics tracker that:

  1. Accepts a stream of numbers via send()
  2. Yields a dict of {mean, min, max, count} after each received value
  3. Supports throw(ResetStats) to reset all statistics
  4. Handles cleanup in a finally block that prints a summary
  5. Uses yield from to delegate to a sub-generator that handles the reset logic
# Target usage:
tracker = stats_tracker()
next(tracker) # prime

print(tracker.send(10)) # {'mean': 10.0, 'min': 10, 'max': 10, 'count': 1}
print(tracker.send(20)) # {'mean': 15.0, 'min': 10, 'max': 20, 'count': 2}
print(tracker.send(5)) # {'mean': 11.67, 'min': 5, 'max': 20, 'count': 3}

tracker.throw(ResetStats)
print(tracker.send(100)) # {'mean': 100.0, 'min': 100, 'max': 100, 'count': 1}

tracker.close()
# prints: "Tracker closed. Final count: 1"
Show Reference Solution
class ResetStats(Exception):
"""Signal to reset running statistics."""
pass


def _stats_accumulator():
"""Sub-generator: accumulate stats until ResetStats is thrown."""
total = 0.0
count = 0
minimum = None
maximum = None

while True:
try:
value = yield
count += 1
total += value
minimum = value if minimum is None else min(minimum, value)
maximum = value if maximum is None else max(maximum, value)
# Yield the stats back to outer generator
yield {
"mean": round(total / count, 2),
"min": minimum,
"max": maximum,
"count": count,
}
except ResetStats:
# Reset and start fresh
total = 0.0
count = 0
minimum = None
maximum = None
yield None # acknowledge the reset


def stats_tracker():
"""
Coroutine that tracks running statistics.
send(number) -> yields updated stats dict
throw(ResetStats) -> resets all statistics
"""
final_count = 0
try:
while True:
try:
# Receive a value from caller
value = yield
if value is None:
continue

# Update stats inline (simplified - no sub-generator for clarity)
# Restart accumulation state each iteration
pass

except ResetStats:
yield None # acknowledge reset

finally:
print(f"Tracker closed. Final count: {final_count}")


# Cleaner implementation without yield from complexity:
def stats_tracker():
"""Coroutine tracking running statistics with reset support."""
count = 0
total = 0.0
minimum = None
maximum = None

try:
while True:
try:
value = yield {
"mean": round(total / count, 2) if count else 0.0,
"min": minimum,
"max": maximum,
"count": count,
}
if value is not None:
count += 1
total += value
minimum = value if minimum is None else min(minimum, value)
maximum = value if maximum is None else max(maximum, value)

except ResetStats:
count = 0
total = 0.0
minimum = None
maximum = None
yield None # acknowledge the reset; caller primes again with next()

except GeneratorExit:
print(f"Tracker closed. Final count: {count}")
return


# Usage
tracker = stats_tracker()
next(tracker) # prime - reaches first yield, yields initial empty stats

print(tracker.send(10))
# {'mean': 10.0, 'min': 10, 'max': 10, 'count': 1}

print(tracker.send(20))
# {'mean': 15.0, 'min': 10, 'max': 20, 'count': 2}

print(tracker.send(5))
# {'mean': 11.67, 'min': 5, 'max': 20, 'count': 3}

tracker.throw(ResetStats) # reset; yields None (the acknowledgement)
next(tracker) # re-prime after reset

print(tracker.send(100))
# {'mean': 100.0, 'min': 100, 'max': 100, 'count': 1}

tracker.close()
# prints: Tracker closed. Final count: 1

Key design decisions:

  • yield value in the middle of the loop both sends stats out and receives the next input - the two-directional channel of coroutines
  • except ResetStats inside the generator catches throw(ResetStats) at the yield point
  • except GeneratorExit in the outer try handles close() gracefully for cleanup
  • yield None after a reset acknowledges the throw and allows the caller to re-prime with next()
  • The generator preserves full state (count, total, min, max) across all send() calls - this is the point of frame suspension

What's Next

Lesson 04 covers the Iterator Protocol in full - __iter__ and __next__, building custom iterators from scratch, infinite sequences with __iter__, and how Python's for loop is actually implemented as a series of next() calls wrapped in a StopIteration try/except.

You have already seen the iterator protocol in action through generators. Now you will build it explicitly, which reveals why generators are so useful - they implement __iter__ and __next__ automatically, saving you from writing the boilerplate by hand.

© 2026 EngineersOfAI. All rights reserved.