The send() method is the fundamental mechanism for two-way communication with a generator. While next() is used to retrieve a value from a generator, send() allows you to push a value into a generator while it is paused at a yield expression. This transforms a generator from a simple producer of values into a co-operative routine that can both consume and produce data, hence the term “coroutine.”

The Mechanics of send()

When a generator function is first called, it returns a generator object in a state prior to the first yield. You cannot send a value into this initial state because there is no yield expression ready to receive it. Therefore, the first interaction with a generator must either be a call to next() or send(None). This “primes” the generator, advancing its execution to the first yield point.

Once the generator is suspended at a yield expression, send(value) performs two actions:

  1. It resumes the generator’s execution.
  2. It causes the paused yield expression to evaluate to the value that was sent.

The generator then continues its execution until it hits the next yield (which returns the next value to the caller of send()), a return statement (which raises a StopIteration), or the end of the function (which also raises StopIteration).

def coroutine():
    print("Coroutine started, awaiting initial value...")
    x = yield "READY"  # The first yield returns 'READY' and pauses.
    print(f"Coroutine received: {x}")
    y = yield f"PROCESSED: {x * 2}"  # This yield returns a string and pauses again.
    print(f"Coroutine received: {y}")
    yield "DONE"

# Initialize the generator
gen = coroutine()

# Prime the generator to the first yield. Must use next() or send(None).
first_yield_value = next(gen)
print(f"Received from generator: {first_yield_value}")

# Send a value (10) into the generator. It is assigned to variable 'x'.
second_yield_value = gen.send(10)
print(f"Received from generator: {second_yield_value}")

# Send another value (99) into the generator. It is assigned to 'y'.
final_yield_value = gen.send(99)
print(f"Received from generator: {final_yield_value}")

Output:

Coroutine started, awaiting initial value...
Received from generator: READY
Coroutine received: 10
Received from generator: PROCESSED: 20
Coroutine received: 99
Received from generator: DONE

The Initial Priming Step

The requirement to “prime” the generator is a common source of errors. Attempting to send() a non-None value to an unprimed generator will result in a TypeError.

def simple_gen():
    received = yield "Hello"
    print(f"Received: {received}")

gen = simple_gen()
try:
    gen.send(42)  # Trying to send a value before priming
except TypeError as e:
    print(f"Error: {e}")  # Output: Error: can't send non-None value to a just-started generator

This behavior exists because the generator’s execution begins at the top of the function, not at the first yield. There is no active yield expression to receive the value until the generator is advanced to that point. The first next(gen) call executes the code from the function’s start up to and including the first yield expression. The yield produces its value (“Hello”), but the assignment to received has not happened yet; it is waiting for the generator to be resumed by a send().

Distinction from next()

It’s crucial to understand that next(gen) is functionally equivalent to gen.send(None). Both advance the generator to the next yield. The next() function is essentially a convenience method for when you don’t need to send any data into the coroutine, only receive data from it. This makes next() ideal for the initial priming step and for interacting with generators designed purely as data producers.

Common Pitfalls and Best Practices

A major pitfall is failing to handle the final StopIteration when the coroutine completes. Once a generator returns (either via an explicit return or by reaching the end of the function), any subsequent call to send() or next() will raise StopIteration. In complex coroutine-based systems, this exception must be caught and handled appropriately to signal the completion of a task.

Another best practice is to always close your generators when you are done with them, especially if they might have finally blocks for resource cleanup. While the garbage collector will eventually close them, explicit closure is safer and more predictable.

def coroutine_with_cleanup():
    try:
        while True:
            data = yield
            print(f"Processing: {data}")
    except GeneratorExit:
        print("Generator closing, performing cleanup...")
    finally:
        print("Finally block executed.")

gen = coroutine_with_cleanup()
next(gen)  # Prime it
gen.send('A')
gen.send('B')
gen.close()  # Explicitly close to trigger cleanup

Output:

Processing: A
Processing: B
Generator closing, performing cleanup...
Finally block executed.

Understanding send() is the key to unlocking the full power of generators as coroutines. It establishes the foundational pattern for more advanced concurrency frameworks like asyncio, where the event loop uses send() and throw() to manage the execution of thousands of concurrent tasks.