Most introductions to Python generators focus on their role as value producers: you call next(), the generator runs until yield, and a value comes out. That is the one-way model, and it covers the majority of generator use cases in everyday Python code. But the yield keyword can do more. When you write yield as an expression and assign its result to a variable, the generator becomes a two-way communication channel. The caller can send values into the generator at each pause point using the send() method, and the generator can use those values to influence what it produces next. This capability transforms generators from simple iterators into lightweight coroutines that can maintain complex state while processing a stream of incoming data.
Understanding bidirectional generators opens up patterns that are difficult or clumsy to express with classes or callbacks. You can write a running-average calculator that receives numbers one at a time and yields the updated average after each input. You can write a state machine that transitions between states based on messages sent by the caller. You can write a parser that consumes tokens incrementally and yields parsed expressions as they become complete. All of these patterns share a common structure: the generator maintains internal state, receives values through yield expressions, and produces output through yield statements, all within a single function body whose control flow is easy to follow.
If you are comfortable with creating generator functions with yield, you already know how yield pauses execution and hands a value back. Adding send() to the picture means the value can flow in both directions at each pause point. The yield expression simultaneously sends a value out to the caller and receives a value in from the caller, unifying input and output in a single language construct.
yield as an expression
In the generators you have written so far, yield appears as a statement: yield value. The yielded value is sent to the caller, and the yield itself produces no useful result inside the generator. When you write received = yield value, the yield becomes an expression. The value on the right of yield is still sent out to the caller, but now the yield expression also evaluates to a value, the value that the caller passes in with send(), or None if the caller uses next().
This dual role of yield, sending a value out and receiving a value in at the same point in the code, is what makes bidirectional generators possible. When the generator resumes, the yield expression completes, and the value passed by the caller is assigned to the variable on the left of the equals sign. The generator can then use that received value in the logic that follows, right up until the next yield.
Here is a generator that accumulates a running total. Each time it resumes, it receives a number through yield, adds it to the running total, and yields the new total back:
def running_total():
total = 0
while True:
received = yield total
if received is None:
break
total += receivedThe first call to this generator must be next() or send(None) to prime it, advancing execution to the yield. The yield returns the current total, which starts at zero, and pauses. A subsequent call to send(10) resumes the generator with 10 as the result of the yield expression, which is assigned to received. The total becomes 10, and the loop continues to the next yield, which sends 10 back to the caller. The caller sees 10 and can send another number to continue the accumulation.
Using this generator requires a specific protocol. Prime it with next(), then alternate between receiving the yielded total and sending the next number. When you want to stop, send None, which the generator detects and breaks out of the loop, ending the generator cleanly:
acc = running_total()
next(acc)
print(acc.send(5))
print(acc.send(3))
print(acc.send(None))This prints 5, then 8, then raises StopIteration. The generator never holds a list of all the numbers; it only stores the running total, making it suitable for accumulating values from a stream of any length.
The send() protocol
The send() method is the caller's mechanism for injecting values into a paused generator. Calling gen.send(value) does two things: it passes value into the generator as the result of the yield expression where the generator is paused, and it resumes execution until the next yield, returning whatever that yield produces. If the generator raises StopIteration instead of yielding, the StopIteration propagates to the caller.
There is an important restriction on the first call to a generator. A freshly created generator has not yet reached a yield expression, so there is no yield to receive a value. Calling send() with a non-None value on a fresh generator raises TypeError. You must prime the generator first by calling next(gen) or gen.send(None), which advances the generator to its first yield without passing a meaningful value. After priming, you can call send() with any value.
This priming requirement is a common source of bugs when working with bidirectional generators. A defensive pattern is to wrap the generator in a decorator or a helper function that primes it automatically, so the caller can start using send() immediately. The decorator calls next() on the generator once and then returns it, ready to receive values:
def primed(gen_func):
def wrapper(*args, **kwargs):
g = gen_func(*args, **kwargs)
next(g)
return g
return wrapperWith this decorator applied to a generator function, the caller can use send() on the first call without worrying about the priming step. This pattern is common in libraries that expose coroutine-style generators to users who should not need to know about the internal priming requirement.
Practical two-way generator patterns
The most immediately useful pattern for bidirectional generators is a stateful filter that processes a stream of values and maintains context across items. A deduplication generator receives values one at a time and yields only values it has not seen before, maintaining a set of seen values internally. The caller sends each value in and receives either the value itself, if it is new, or None, if it is a duplicate.
Another practical pattern is a sliding-window aggregator. The generator receives a stream of numeric values and yields a tuple containing the current value and the average of the last N values. The generator maintains a fixed-size deque of recent values, updating it on each send() call and yielding the computed result:
from collections import deque
def sliding_average(window_size):
window = deque(maxlen=window_size)
while True:
value = yield
window.append(value)
yield sum(window) / len(window)This generator has an interesting structure: it uses two yield statements per iteration. The first yield receives a value from the caller without producing output. The second yield computes and returns the current average. The caller must call next() or send() twice per data point: once to feed the value and once to retrieve the average. This protocol is unconventional compared to simple value-producing generators, which is a sign that the design might be better expressed as a class. When the protocol between caller and generator becomes complex, the clarity benefit of generators over classes diminishes.
The throw() and close() methods
Generators expose two additional methods beyond send() and next() that give the caller control over the generator's lifecycle. The throw() method raises an exception at the point where the generator is paused. The generator can catch this exception and handle it, or let it propagate, which terminates the generator. This is useful for signalling error conditions or requesting that the generator change its behaviour without terminating.
The close() method raises GeneratorExit at the generator's pause point. The generator is allowed to catch GeneratorExit to run cleanup code, but it must then finish: it can re-raise the exception or simply return, and it must not yield another value. If a generator yields a value after receiving GeneratorExit, Python raises RuntimeError, because the whole point of close() is that no further values should be produced. The close() method is the standard way to tell a generator to perform cleanup and terminate, typically when the caller is done consuming values before the generator is exhausted:
def managed_resource():
try:
while True:
value = yield
print(f"processing {value}")
finally:
print("releasing resource")When the caller calls close() on this generator, GeneratorExit is raised at the current yield, the finally block runs to release the resource, and the generator terminates. The caller does not need to know the details of the cleanup; it just calls close() and trusts the generator to handle its own teardown.
When to use two-way generators
Bidirectional generators are powerful but easy to overuse. If the caller and the generator need to exchange values in a strict request-response pattern, with the caller always sending a value and the generator always yielding one in return, a generator with send() is a natural fit. If the protocol is more complex, with the generator sometimes producing multiple values per input or sometimes skipping production, the control flow can become hard to follow when expressed as yield expressions alternating with yield statements inside a single function body.
A good litmus test is whether you can describe the protocol in a single sentence. "I send numbers in, and the generator yields the running total" is clear. "I send numbers in, sometimes the generator yields the average, sometimes it yields the median, and sometimes it asks for more input by yielding a special sentinel" suggests that a class with well-named methods would be easier to understand and maintain.
When the protocol is simple and the generator's state is small, two-way generators eliminate the boilerplate of a class while keeping the logic in a single readable function. When the protocol grows complex, the article on Python iterators versus generators provides guidance on choosing between generator functions and custom iterator classes for different scenarios.
Rune AI
Key Insights
- An assignment like received = yield value makes yield an expression that both sends value out and receives input from the caller.
- The send() method resumes a generator and passes a value that becomes the result of the yield expression.
- A generator must be primed with next() or send(None) before send() can pass a non-None value.
- throw() injects an exception into the generator at its pause point; close() raises GeneratorExit to trigger cleanup.
- Two-way generators enable coroutine patterns where the generator acts as a stateful processor receiving incremental input.
Frequently Asked Questions
What does the send() method do on a generator?
What is the difference between next() and send() on a generator?
What happens if I call send() with a non-None value on a generator that has not reached a yield yet?
Conclusion
The yield keyword is not just an output mechanism. When used as an expression on the right side of an assignment, yield receives values sent by the caller through the send() method. This transforms generators from simple value producers into coroutines that can maintain state, process incremental input, and respond to the caller dynamically. The throw() and close() methods complete the communication protocol, giving the caller full control over the generator's lifecycle.
More in this topic
Create Custom Iterators in Python
Learn how to build your own iterators in Python by implementing the __iter__() and __next__() methods, with practical examples and reusable patterns.
Python Iterator Protocol Explained
Learn how Python's iterator protocol works with the __iter__() and __next__() methods, and understand the contract every iterator must follow.
Python Iterables and Iterators Explained
Learn what iterables and iterators are in Python, how they power for loops under the hood, and why understanding them unlocks cleaner data processing patterns.