Hey Python enthusiasts! ๐
In Part 1 Iterables: The Buffet Table ๐ฝ๏ธ, we learned how Python iterables act like an endless buffet full of data.
In Part 2 Iterators: Python's Waiters with One-Way Memory ๐ด๐ค, we met the waiters who serve dishes one at a time while remembering where they left off.
In Part 3 Advanced Iteration Tricks: From Buffets to Conveyor Belts ๐ฒ, we upgraded our restaurant with kitchen gadgets, sous-chefs, delegation, and lazy conveyor belts.
But what if our waiters could do something even more impressive?
What if they could:
- Serve dishes
- Take new orders
- Handle complaints
- React to customer feedback
- Coordinate with other waiters in real time
Welcome to the world of coroutines.
This is where generators evolve from simple one-way data producers into interactive two-way communication channels.
Today we're diving deep into:
- What coroutines are
- How
.send()works - Why generators need priming
- Using
.throw()and.close() - Building coroutine pipelines
- Async/Await and modern coroutines
- Memory and performance characteristics
- Real-world applications and best practices
Ready to meet Python's smartest waiters?
Let's dive in. ๐
๐ง What Is a Coroutine?
A coroutine is a special kind of generator that can:
- Produce values
- Receive values
- Handle exceptions
- Maintain internal state
Think of a normal generator as a waiter who can only serve food.
A coroutine is a waiter who can:
- Serve food
- Take orders
- Respond to complaints
- Update the menu while working
Communication becomes two-way.
Instead of:
Generator โ Consumer
We now have:
Consumer โ Coroutine
This small shift unlocks incredibly powerful programming patterns.
๐ฝ๏ธ Generators vs Coroutines
Let's start with a regular generator.
def waiter():
yield "Serving dish 1"
yield "Serving dish 2"
w = waiter()
print(next(w))
print(next(w))
Output:
Serving dish 1
Serving dish 2
The waiter talks.
The customer listens.
Now let's make the waiter interactive.
def interactive_waiter():
while True:
order = yield "What would you like?"
print(f"๐ฒ Serving {order}")
Now customers can talk back.
That's the essence of coroutines.
๐ค Enter .send() โ Talking to Waiters
The .send() method allows data to flow into a paused generator.
def interactive_waiter():
print("๐จโ๐ณ Ready to take your order.")
while True:
dish = yield "What would you like?"
print(f"๐ฒ Serving {dish}...")
Usage:
w = interactive_waiter()
print(next(w))
print(w.send("pasta"))
print(w.send("steak"))
Output:
๐จโ๐ณ Ready to take your order.
What would you like?
๐ฒ Serving pasta...
What would you like?
๐ฒ Serving steak...
What would you like?
Magic happens here:
dish = yield "What would you like?"
The generator pauses at yield.
Later:
w.send("pasta")
injects "pasta" back into the paused generator.
The coroutine resumes exactly where it left off.
โ๏ธ What Happens Inside .send()?
When you call:
w.send("pasta")
Python:
- Resumes the suspended generator frame
- Injects
"pasta"into the current yield expression - Assigns it to
dish - Continues execution
- Stops again at the next
yield
Conceptually:
Paused Coroutine
โ
send()
โ
Resume Execution
โ
Run Code
โ
Next Yield
โ
Pause Again
Unlike normal functions, coroutines remember their complete execution state.
That's what makes them so powerful.
๐ Why Do We Need Priming?
A common beginner mistake:
w = interactive_waiter()
w.send("pasta")
Results in:
TypeError:
can't send non-None value to a just-started generator
Why?
Because execution hasn't reached the first yield yet.
You must first start the coroutine:
next(w)
Or:
w.send(None)
This process is called priming.
Think of it as getting the waiter's attention before placing your order.
๐ Real-World Example: Live Event Processor
Imagine a real-time analytics system receiving events continuously.
def event_collector():
total = 0
try:
while True:
value = yield f"Total so far: {total}"
total += value
except GeneratorExit:
print(f"Final total: {total}")
Using it:
collector = event_collector()
print(next(collector))
print(collector.send(10))
print(collector.send(5))
print(collector.send(20))
collector.close()
Output:
Total so far: 0
Total so far: 10
Total so far: 15
Total so far: 35
Final total: 35
This pattern appears frequently in:
- Metrics systems
- Monitoring dashboards
- Event streaming platforms
- Real-time analytics
๐ช .close() โ Sending the Waiter Home
Eventually the shift ends.
The waiter needs to go home.
That's what .close() does.
coroutine.close()
Behind the scenes Python raises:
GeneratorExit
inside the coroutine.
This allows cleanup logic:
def waiter():
try:
while True:
yield
except GeneratorExit:
print("๐ Restaurant closed.")
This is similar to resource cleanup in:
- Database connections
- Network sockets
- File handlers
๐งจ .throw() โ Throw Problems at the Waiter
Sometimes things go wrong.
Coroutines can handle errors dynamically.
def chef():
try:
while True:
dish = yield
print(f"Cooking {dish}")
except ValueError:
print("๐ฅ Wrong ingredient!")
Usage:
c = chef()
next(c)
c.send("pasta")
c.throw(ValueError)
c.send("salad")
Output:
Cooking pasta
๐ฅ Wrong ingredient!
Cooking salad
The exception is injected directly into the running coroutine.
This allows sophisticated error recovery patterns.
๐ญ Building Coroutine Pipelines
Now things get interesting.
Instead of one waiter, we create an entire restaurant workflow.
def logger():
while True:
item = yield
print(f"๐งพ Logged: {item}")
def vegetarian_filter(target):
while True:
dish = yield
if "๐ฅฉ" not in dish:
target.send(dish)
Connect them:
log = logger()
next(log)
veg = vegetarian_filter(log)
next(veg)
for dish in [
"๐ฅ salad",
"๐ pasta",
"๐ฅฉ steak",
"๐ฐ cake"
]:
veg.send(dish)
Output:
๐งพ Logged: ๐ฅ salad
๐งพ Logged: ๐ pasta
๐งพ Logged: ๐ฐ cake
Each coroutine performs one responsibility.
Together they form a processing pipeline.
๐ Producer โ Consumer Architectures
Coroutine pipelines are common in:
- Log processing
- Message queues
- ETL systems
- Event streaming
- Sensor networks
Visualized:
Producer
โ
Filter
โ
Transformer
โ
Logger
โ
Storage
Each stage operates independently.
Data flows continuously.
Memory usage stays low.
โก Async/Await โ Modern Coroutines
Generator-based coroutines laid the foundation for modern async programming.
Today we typically use:
async def
await
instead of .send() directly.
Example:
import asyncio
async def waiter(name, delay):
print(f"{name} started taking orders...")
await asyncio.sleep(delay)
print(f"{name} finished!")
Running multiple waiters:
async def main():
await asyncio.gather(
waiter("๐จโ๐ณ Chef 1", 2),
waiter("๐ฉโ๐ณ Chef 2", 3)
)
asyncio.run(main())
Output:
๐จโ๐ณ Chef 1 started taking orders...
๐ฉโ๐ณ Chef 2 started taking orders...
๐จโ๐ณ Chef 1 finished!
๐ฉโ๐ณ Chef 2 finished!
This is coroutine magic powering:
- FastAPI
- aiohttp
- Uvicorn
- Async database drivers
- Modern web servers
๐ง Memory & Performance Characteristics
Coroutines are incredibly lightweight.
Each coroutine stores:
- Local variables
- Current execution position
- Generator frame
- Exception state
Unlike threads, coroutines do not require:
- Separate stacks
- Context switching by the OS
- Heavy memory allocation
This allows thousands or even hundreds of thousands of coroutines to coexist efficiently.
That's one reason asynchronous frameworks scale so well.
โ ๏ธ Common Pitfalls
Forgetting to Prime
c.send("hello")
Without:
next(c)
you'll get a TypeError.
Coroutine Exhaustion
Once closed:
c.close()
you cannot restart it.
Create a new coroutine instead.
Infinite Loops
Many coroutines use:
while True:
Always provide a proper shutdown mechanism.
Swallowed Exceptions
Poor exception handling can silently terminate coroutines.
Always catch expected exceptions carefully.
๐จ Mental Model
Traditional Function
Input
โ
Function
โ
Output
Generator
Generator
โ
yield
โ
Consumer
Coroutine
Consumer
โ
Coroutine
โ
Consumer
The biggest conceptual leap:
Generators produce data.
Coroutines communicate with data.
TL;DR Quick Recap
- Coroutines are generators with two-way communication.
.send()pushes values into paused generators..throw()injects exceptions..close()shuts generators down cleanly.- Coroutines maintain execution state between pauses.
- Coroutine pipelines enable reactive architectures.
- Async/Await evolved from coroutine concepts.
- Coroutines are lightweight and memory efficient.
Final Thoughts: The Waiter Learns to Listen ๐ง
When developers first learn generators, they often see them as a clever way to save memory.
But coroutines reveal something deeper.
A generator doesn't have to be a simple data producer.
It can become an active participant in a conversation.
This shift from:
Output-only
to:
Two-way communication
is what ultimately led to modern asynchronous programming in Python.
Understanding coroutines gives you a much deeper appreciation for how tools like FastAPI, asyncio, and async/await really work under the hood.
A Little Joke to End On ๐
Why did the coroutine become Employee of the Month?
Because it could listen and talk at the same time without blocking anyone.
Frequently Asked Questions
What is a coroutine in Python?
A coroutine is a special type of generator that can both produce values and receive values using methods such as .send().
What's the difference between a generator and a coroutine?
Generators primarily produce data.
Coroutines support two-way communication and can react to incoming values.
Why must coroutines be primed?
The generator must first reach its initial yield before values can be sent into it.
What does .send() do?
It injects a value into a paused coroutine and resumes execution until the next yield.
What does .throw() do?
It raises an exception inside the coroutine, allowing internal error handling.
What does .close() do?
It raises GeneratorExit, allowing the coroutine to clean up resources and terminate gracefully.
Are coroutines the same as async/await?
Not exactly.
Modern async/await builds upon ideas originally introduced through generator-based coroutines.
Are coroutines memory efficient?
Yes.
They require far less memory than traditional threads because they maintain lightweight execution state.
Where are coroutines used in real applications?
Common use cases include:
- Web servers
- Event processing
- Streaming systems
- Async APIs
- Real-time analytics
- Message queues
Key Takeaways
- Coroutines extend generators with two-way communication.
send()injects values into running generators.throw()injects exceptions.close()enables graceful shutdown.- Coroutine pipelines enable reactive data processing.
- Async/Await evolved from coroutine concepts.
- Coroutines are lightweight and highly scalable.
- Understanding coroutines helps demystify modern asynchronous Python.
If you found this article helpful, share it with another Python developer and follow along for more deep dives into Python internals, asynchronous programming, and software architecture. ๐
About the Author
Anik Sikder is a Software Engineer specializing in Python, Django, FastAPI, Backend Systems, Cloud Infrastructure, System Design, and Software Architecture.
He writes about Python internals, JavaScript, distributed systems, asynchronous programming, scalable backend engineering, and modern software architecture.



