Introduction
In our last lesson, we refactored rigid if/elif/else chains into a flexible, extensible dictionary-of-callables. This "Strategy Pattern" is perfect for selecting a behavior based on an input. Today, we address a related but distinct challenge: managing a process that moves through a sequence of states over time.
You've likely implemented state machines before, perhaps using a switch statement in Java or a similar if/elif/else structure in Python, manually managing a state variable. This often leads to convoluted code that's hard to follow. The learning outcome for this lesson is to model a simple state machine using a generator coroutine that accepts values via its .send() method.
This is a powerful, elegant, and deeply Pythonic technique. By turning a generator "inside-out" to receive data, we can write complex, stateful logic that reads like a simple, sequential script, encapsulating state within the generator's local scope.
From Generators to Coroutines: The yield Expression
So far, we've used yield as a statement to produce values from a generator. The key insight that unlocks coroutines is that yield can also be an expression that receives values.
Consider this line:received_value = yield
When the generator is paused at this line, an external caller can use the .send(data) method. The data sent by the caller becomes the result of the yield expression and is assigned to received_value. This creates a two-way communication channel.
The execution flow has a critical first step: priming. A new generator hasn't reached a yield expression yet, so you can't send it data. You must first call next() on it to advance it to its first yield point.
Let's visualize this flow.

This mechanism is the foundation for using generators to model stateful processes.
For a definitive, step-by-step explanation of this behavior, including the different states a coroutine can be in (GEN_CREATED, GEN_SUSPENDED, GEN_CLOSED), let's turn to the 'Fluent Python' book.
Read the first two parts of this chapter: "Basic Behavior of a Generator Used as a Coroutine": Focus on the simple_coro2 example and the state diagram. Internalize the sequence: create, prime with next(), then communicate with send(). "Example: Coroutine to Compute a Running Average": Study the averager coroutine. This is a perfect, minimal example of a state machine. It maintains internal state (total, count) as local variables and updates its state with each value sent to it.
Modeling a Finite State Machine (FSM)
Now, let's apply this to a classic finite state machine. Imagine a simple digital turnstile. It has two states: LOCKED and UNLOCKED.
- In the
LOCKEDstate, if you insert a coin, it becomesUNLOCKED. If you push it, it staysLOCKED. - In the
UNLOCKEDstate, if you push it, it lets you through and becomesLOCKED. If you insert another coin, it staysUNLOCKED.
A traditional implementation might look like this:
# The "old" way with explicit state variable and conditionals
def turnstile_machine():
state = 'LOCKED'
while True:
event = yield state # yield the current state, wait for an event
if state == 'LOCKED':
if event == 'coin':
print("-> UNLOCKED")
state = 'UNLOCKED'
elif event == 'push':
print("-> CLANG! (Still LOCKED)")
elif state == 'UNLOCKED':
if event == 'push':
print("-> Welcome! (Now LOCKED)")
state = 'LOCKED'
elif event == 'coin':
print("-> Thank you! (Still UNLOCKED)")
# --- Driver code ---
ts = turnstile_machine()
current_state = next(ts) # Prime it and get initial state
print(f"Initial State: {current_state}")
current_state = ts.send('push') # Stays locked
print(f"State: {current_state}")
current_state = ts.send('coin') # Unlocks
print(f"State: {current_state}")
current_state = ts.send('push') # Locks again
print(f"State: {current_state}")
This works, but the logic is tangled in if/elif blocks. As the number of states and events grows, this becomes unmanageable.
The Coroutine Approach: States as Functions
A much cleaner approach is to model each state as its own coroutine (or a loop within a single coroutine). This localizes the logic for each state.
Let's see an elegant implementation structure.

This structure is powerful. Each state is a self-contained unit of logic. It defines its own transitions and what happens on valid or invalid input. The StopIteration that occurs when a state's generator ends is used as a signal for an invalid transition.
Python Generators 2: send and yield
To see this bi-directional communication in a dynamic, fun example, let's watch this video that builds a cat-and-mouse chase game using two interacting coroutines.
Focus on these key parts: 05:35 - 08:50: See how the cat's position is updated. The line mouse = yield cat is the core of it: the coroutine yields the cat's current position and receives the mouse's position in return, which it then uses to decide its next move. This is a state machine whose state is its position. 08:50 - 12:32: The example is expanded so the mouse also runs away from the cat, showing full two-way communication. It also shows how raise StopIteration can be used to explicitly end the coroutine (the 'game').
Deeper Dive: Schedulers and Advanced Coroutines
The taxi_sim.py example from the Fluent Python chapter is an advanced application of this concept. Each taxi_process is a state machine moving through states like 'leave garage', 'pick up passenger', and 'drop off passenger'. The Simulator class acts as a scheduler, driving multiple taxi coroutines concurrently by sending them time updates. This is the conceptual basis for modern asynchronous frameworks like asyncio.
For the most authoritative and low-level explanation, David Beazley's presentations are legendary. His PDF on coroutines directly connects them to state machines and building operating-system-like schedulers.
This is dense material, so focus on the high-level ideas: Pages 63-65 (Section buses_to_dicts): This shows an XML parser implemented as a state machine with two states: 'Looking for a bus' and 'Collecting bus attributes'. This is a direct, practical application. Pages 92-98 (Coroutines as Tasks): These slides make the argument that coroutines have all the properties of concurrent 'tasks'. Pages 113-122 (Task and Scheduler): Skim these pages to see how a simple scheduler can be built to run multiple coroutines, just like the taxi simulation. This shows how the .send() and yield mechanisms form the building blocks of cooperative multitasking.
Exercise: A Stateful Traffic Light
Your task is to model a traffic light as a state machine using a single generator coroutine.
Specification:
- The coroutine,
traffic_light(), should model a traffic light that cycles throughGREEN,YELLOW, andRED. - It should accept two events sent to it:
'tick'(time passes) and'emergency'(an emergency vehicle approaches). - The coroutine should
yieldits current color as a string. - The cycle logic:
- Starts
GREEN. StaysGREENfor 3'tick's. - Transitions to
YELLOW. StaysYELLOWfor 1'tick'. - Transitions to
RED. StaysREDfor 2'tick's. - Transitions back to
GREENand repeats.
- Starts
- If it receives an
'emergency'event at any time, it should immediately transition toRED,yield 'RED', and then continue its normal cycle from theREDstate.
Starter Code:
def traffic_light():
# Your implementation here
# Use an infinite loop and nested loops for each state.
# `event = yield color` will be your core mechanic.
pass # remove this line
# --- Driver Code (Do not change) ---
if __name__ == "__main__":
light_coro = traffic_light()
# Prime the coroutine and print its initial state
current_color = next(light_coro)
print(f"Light is {current_color}")
# A list of events to send to the traffic light
events = ['tick', 'tick', 'tick', 'tick', 'tick', 'tick', 'emergency', 'tick', 'tick']
for event in events:
print(f"\nSending: '{event}'")
current_color = light_coro.send(event)
print(f"Light is {current_color}")
Solution
def traffic_light():
"""A state machine for a traffic light, implemented as a coroutine."""
while True:
# --- GREEN STATE ---
color = 'GREEN'
for _ in range(3):
event = yield color
if event == 'emergency':
break # Exit to the emergency handler below
else: # This `else` belongs to the `for` loop
# --- YELLOW STATE ---
color = 'YELLOW'
event = yield color
if event == 'emergency':
pass # Will be handled by the outer emergency check
# --- RED STATE / EMERGENCY HANDLER ---
color = 'RED'
for _ in range(2):
event = yield color
# An emergency during RED just resets its timer
if event == 'emergency':
continue
Conclusion
You have now learned how to use one of Python's most powerful and expressive features. By leveraging generator coroutines, you can model complex, stateful logic in a way that is clean, readable, and avoids the tangled mess of nested conditionals.
Key Takeaways:
- Coroutines are State Machines: Generators become coroutines when you use
yieldas an expression to receive data via.send(). This makes them ideal for implementing state machines. - State is Encapsulated: The state of the process is maintained in the coroutine's local variables between
yieldpoints, eliminating the need for instance variables or global state managers. - Logic is Sequential: Stateful logic, which is often spread across complex conditional blocks, can be written as a straightforward, sequential script inside the coroutine.
- Priming is Essential: A coroutine must be advanced to its first
yieldwithnext()before you can.send()it any data.
In our next module, we will shift gears from abstract control flow patterns to the practical realities of I/O. Our first lesson will cover how to use pathlib.Path for modern, object-oriented, and cross-platform path manipulation, replacing the older, string-based os.path module.