While generators are most commonly known for producing sequences of values using yield, they also support a powerful two-way communication mechanism through the .send() method. This method transforms a generator from a simple producer of data into a coroutine—a more collaborative function that can both receive and emit values, maintaining state between interactions.

The send() Method and the yield Expression

The key to understanding .send() lies in recognizing that yield is not just a statement; it’s an expression. When a generator is paused at a yield statement, the execution can be resumed in two ways:

  1. With next(): The yield expression evaluates to None.
  2. With .send(value): The yield expression evaluates to the value provided.

This means the line x = yield y does two things, but at different times. When execution first reaches this line, it yields the value y to the caller and pauses. When the generator is resumed with .send(some_value), it receives some_value, assigns it to x, and then continues execution until the next yield or the function ends.

def coroutine():
    print("Coroutine started, awaiting initial value...")
    # Execution pauses here after yielding None initially.
    # The value sent via .send() will be assigned to 'received'
    received = yield  # This first yield is for receiving only; it yields None.
    print(f"Received: {received}")
    while True:
        # This yield both sends a value and waits to receive the next one.
        response = yield f"Echo: {received}"
        print(f"Received: {response}")
        received = response

# Initialize the generator. Must advance it to the first yield.
gen = coroutine()
next(gen)  # Output: Coroutine started, awaiting initial value...

# Now we can start sending values.
result = gen.send("Hello")
print(result)  # Output: Echo: Hello

result = gen.send("World")
print(result)  # Output: Echo: World

The Initial next() Call (Priming the Generator)

A critical and often overlooked step is the initial call to next(). A generator must always be advanced to its first yield expression before you can call .send(). This is because upon creation, the generator is at the very beginning of its function body, not yet at a point where it can receive a value. Attempting to .send() a value immediately results in a TypeError.

def my_generator():
    value = yield  # This is the first yield
    print(f"Value received: {value}")

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

# The correct way:
next(gen)  # "Primes" the generator, advancing it to the first `yield`.
gen.send("Success!")  # Output: Value received: Success!

This priming step is so crucial that a common best practice is to decorate coroutines meant for use with .send() with a decorator that handles this initial next() call automatically.

Distinguishing Yield Behavior: None vs. Value

It’s important to carefully design your yield expressions based on whether their primary purpose is to emit a value or to receive one.

  • value = yield: Primarily for receiving. The yielded value is None. Use this when the generator is meant to pause and wait for instruction or data from the caller.
  • value = yield emitted_value: For two-way communication. The generator emits emitted_value and immediately pauses, ready to receive a value that will be assigned to value upon resumption.
  • yield emitted_value: Primarily for emitting. The generator sends out emitted_value and pauses. If resumed with .send(), the sent value is lost unless assigned. This is the standard behavior for producer-style generators.

Handling Termination and the StopIteration Exception

When a generator function returns (either by flowing off the end or via a return statement), it raises a StopIteration exception. This is true whether the final resumption was triggered by next() or .send(). The value attribute of the StopIteration exception will contain the return value.

def accumulator():
    total = 0
    try:
        while True:
            # Receive a value and add it to the total
            new_value = yield
            total += new_value
            # Implicitly yield None here before looping again
    except GeneratorExit:
        # This is called if the generator is closed with .close()
        print(f"Generator closing, final total was: {total}")
        return total  # This return value is attached to the GeneratorExit exception

gen = accumulator()
next(gen)  # Prime it

gen.send(10)
gen.send(5)
gen.send(3)

try:
    gen.send(None) # Sending None continues the loop
except StopIteration as e:
    print(f"The generator finished. Returned value: {e.value}") # Output: The generator finished. Returned value: 18

Common Pitfalls and Best Practices

  1. Forgetting to Prime: Always call next() once on a new generator before the first .send(). This is the most common mistake.
  2. Closing Generators: If a generator with an infinite loop (like our echo example) is no longer needed, you must call .close() on it to prevent resource leaks. This throws a GeneratorExit exception into the generator at the point of the yield, allowing it to perform any cleanup (e.g., closing files, network connections) inside a try...except or finally block.
  3. Use as Coroutines: The primary use case for .send() is implementing coroutines for cooperative multitasking, managing stateful context (like a parser or a network protocol handler), or creating complex data processing pipelines where feedback is required. For simple iteration, use for loops and next().
  4. Alternative to send() for Initialization: Often, the first value “sent” into a generator is actually configuration. A cleaner pattern is to pass this configuration as a regular function argument and use yield purely for two-way communication after initialization.