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 raises StopIteration when exhausted

When Python executes for item in collection:, it calls:

  1. iter(collection) → calls collection.__iter__() to get an iterator
  2. next(iterator) repeatedly → calls iterator.__next__() until StopIteration

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:

  1. Saves the current value of the stack pointer and bytecode counter in the frame
  2. Suspends the frame
  3. 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 — for loops, 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.

pythoniteratorsgeneratorsiteration-protocolbytecodecontrol-flow

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.