Python Control Flow — Deep Dive
The Iteration Protocol
Every for loop in Python relies on the iteration protocol: two special methods that any object can implement to become iterable.
__iter__()— returns an iterator object__next__()— returns the next value, or raisesStopIterationwhen exhausted
When Python executes for item in collection:, it calls:
iter(collection)→ callscollection.__iter__()to get an iteratornext(iterator)repeatedly → callsiterator.__next__()untilStopIteration
You can implement this yourself:
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self # This object is its own iterator
def __next__(self):
if self.current <= 0:
raise StopIteration
val = self.current
self.current -= 1
return val
for n in Countdown(5):
print(n) # 5, 4, 3, 2, 1
Understanding this protocol explains why for works on lists, strings, files, database results, network streams — anything that implements the protocol becomes a first-class participant in Python’s iteration system.
How Bytecode Handles Loops
The Python for loop compiles to bytecode that explicitly calls the iterator protocol:
import dis
def loop():
for x in [1, 2, 3]:
pass
dis.dis(loop)
Simplified output:
GET_ITER # calls iter() on the list
FOR_ITER # calls next(); jumps past loop on StopIteration
STORE_FAST x # stores current value
JUMP_BACKWARD # go back to FOR_ITER
FOR_ITER is a single bytecode instruction that handles both the next() call and the StopIteration catching. The exception handling is baked into the opcode — it’s not a try/except at the Python level.
For while loops, the bytecode is different:
LOAD_FAST condition
POP_JUMP_IF_FALSE end
# loop body
JUMP_BACKWARD
while is just a conditional jump — no iterator machinery.
Generators: Functions That Pause
Generators are Python’s way of writing lazy iterators without implementing __iter__ and __next__ manually.
A generator function contains yield:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
print(next(fib)) # 0
print(next(fib)) # 1
print(next(fib)) # 1
print(next(fib)) # 2
Every call to next() resumes the function from where it yielded. The function’s local state (variables, position in code) is preserved between calls.
What Generators Actually Are
Calling a generator function doesn’t execute it — it returns a generator object. The function body doesn’t run until you start calling next().
def gen():
print("Start")
yield 1
print("Middle")
yield 2
print("End")
g = gen() # Nothing printed yet
print(next(g)) # "Start", then 1
print(next(g)) # "Middle", then 2
next(g) # "End", then StopIteration
Generator Implementation: Frame Objects
CPython implements generators using frame objects. Normally, when a function returns, its frame (local variables, bytecode position, stack) is destroyed. Generator frames are kept alive and can be re-entered.
The generator object stores a reference to the suspended frame. next() resumes it by calling PyEval_EvalFrameEx with the saved frame, which picks up execution from the saved bytecode offset.
The yield bytecode instruction:
- Saves the current value of the stack pointer and bytecode counter in the frame
- Suspends the frame
- Returns the yielded value to the caller
This is cooperative multitasking at the language level — each yield is a voluntary context switch.
Generator Expressions
Syntax sugar for simple generators:
squares = (x**2 for x in range(10)) # Generator, not a list
Compare with list comprehension:
squares_list = [x**2 for x in range(10)] # Creates full list in memory
Generator expressions are lazy — they produce values on demand. For large datasets, this can be the difference between “works fine” and “out of memory.”
Coroutines and async/await
Python 3.5 extended generators into coroutines — generators that can also receive values via send(), and that can await other coroutines.
async def fetch_data(url):
response = await http_client.get(url)
return response.json()
Under the hood, async def functions are coroutines — they produce a coroutine object that an event loop can schedule. await is essentially yield with typing semantics.
The CPython implementation of async/await reuses the generator frame suspension mechanism. An async def function compiles to a code object flagged as CO_COROUTINE, and await expr compiles to GET_AWAITABLE + YIELD_FROM.
The event loop (asyncio’s by default) runs coroutines by repeatedly calling send(None) until StopIteration, which signals completion.
Exception Handling and Control Flow
try/except/finally is control flow — it changes the execution path based on whether exceptions occur:
try:
result = risky_operation()
except ValueError as e:
handle_value_error(e)
except (TypeError, KeyError) as e:
handle_other_error(e)
except Exception:
log_unknown_error()
raise # re-raise the original exception
else:
use_result(result) # only runs if no exception
finally:
cleanup() # always runs
The else clause (runs if no exception) and finally (always runs) are often overlooked. finally is guaranteed to run even if there’s a return in the try block — this makes it ideal for cleanup (closing files, releasing locks).
Exception Propagation
When an exception isn’t caught, Python unwinds the call stack — checking each frame’s exception table until a handler is found or the program terminates. CPython stores exception handling information in a per-code-object exception table (as of Python 3.11), replacing the older approach of exception handling bytecodes with a zero-cost model where no exception has zero overhead.
context managers: with Statements
with is control flow for resource management:
with open("file.txt") as f:
data = f.read()
# f is automatically closed here, even if an exception occurred
Under the hood, with obj: calls obj.__enter__() on entry and obj.__exit__(exc_type, exc_val, exc_tb) on exit. If __exit__ returns truthy, exceptions are suppressed.
You can implement your own context managers with @contextmanager:
from contextlib import contextmanager
@contextmanager
def timer(label):
import time
start = time.perf_counter()
yield
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.3f}s")
with timer("database query"):
results = db.query("SELECT ...")
This uses a generator (yield) to split a function into “before” and “after” parts, which the @contextmanager decorator wraps into __enter__ and __exit__.
One Thing to Remember
Python’s entire iteration system —
forloops, generators,async/await, and comprehensions — is built on the same two-method protocol (__iter__and__next__). Once you implement it yourself, every Python loop makes sense at the deepest level.
See Also
- Python Async Await Async/await helps one Python program juggle many waiting jobs at once, like a chef who keeps multiple pots moving without standing still.
- Python Basics Python is the programming language that reads like plain English — here's why millions of beginners (and experts) choose it first.
- Python Booleans Make Booleans click with one clear analogy you can reuse whenever Python feels confusing.
- Python Break Continue Make Break Continue click with one clear analogy you can reuse whenever Python feels confusing.
- Python Closures See how Python functions can remember private information, even after the outer function has already finished.