Most Python developers use async/await daily but treat it as magic. You slap async in front of a function, await something, and concurrency magically happens. But there is a fascinating chain of evolution from generators to coroutines to the async def syntax we use today — and understanding it makes you a significantly better async Python programmer.
In this deep dive, you will:
- Trace Python’s coroutine evolution from
yieldtoasync def - Understand exactly what
async defcreates — and why it is not a generator - Walk through a step-by-step execution of a real async program
- Master the awaitable protocol and what
__await__really does - Learn why cooperative scheduling is the foundation of async performance
Page Contents
The Evolution: From Generators to async def
To understand where async/await came from, you need to trace Python’s coroutine history.
Before Python 3.4, coroutines in Python were implemented using generators with a yield statement. The problem was: yield was designed for producing values, not for suspending and resuming execution across an entire program. It was a clever hack, but an awkward one.
Python 3.4 introduced asyncio using this generator-based approach. You would see code like:
@asyncio.coroutine
def fetch_data():
response = yield from aiohttp.get('https://api.example.com/data')
return response
The @asyncio.coroutine decorator and yield from were the async primitives. Under the hood, these were still generators — Python was just using them in a novel way.
Python 3.5 made the leap with native coroutines via async def. await was added as a keyword. This was not just syntax sugar — it was a fundamentally new type of object in CPython: the coroutine object, distinct from generator objects. This change is documented in PEP 492.
What async def Actually Creates
When you write:
async def fetch_data(url):
response = await aiohttp.get(url)
return response
Python does not create a generator function. It creates a coroutine function — a different species entirely. Calling fetch_data(url) does not execute the function body. Instead, it returns a coroutine object — a lightweight suspension primitive.
Compare this to a regular function:
def regular_func(x):
return x * 2
result = regular_func(5) # Executes immediately, result = 10
async def async_func(x):
return x * 2
coro = async_func(5) # Does NOT execute — returns a coroutine object
The coroutine object is lazily evaluated. It will not run until something explicitly drives it — typically the event loop.
The State Machine Inside Every async def
Here is the part most articles skip: every async def function is secretly a state machine in disguise.
Python’s compiler transforms your async def into a function that returns a coroutine object. When that coroutine is driven (more on this later), it runs until it hits an await. At that point it yields control — not to a caller, but to whatever is driving it.
But wait — await does not just yield anywhere. It can only await awaitable objects. The most common awaitables are:
- Another coroutine (what
async defreturns) - An
asyncio.Task - An
asyncio.Future
When you await a coroutine, you are suspending the current coroutine and transferring control to the event loop until that coroutine completes. This is cooperative multitasking at its finest.
Step-by-Step: A Simple Async Program
Let me walk through exactly what happens when you run this:
import asyncio
async def say_after(what, delay):
await asyncio.sleep(delay)
print(what)
async def main():
await say_after('hello', 1)
await say_after('world', 0.5)
asyncio.run(main())
Step 1: asyncio.run(main())
asyncio.run() does three things:
- Creates a new event loop
- Creates a
Taskwrapping themain()coroutine - Runs the loop until
main()completes
Step 2: Task wraps main()
asyncio.run() does not run main() directly. It wraps it in a Task:
task = asyncio.create_task(main()) # This is what actually happens inside asyncio.run()
A Task is a subclass of Future that wraps a coroutine and schedules it on the event loop. The key insight: Task is what drives your coroutine forward.
Step 3: The event loop runs main()
The event loop calls task.step() — this drives the main() coroutine forward. The coroutine begins executing:
async def main():
# Line 1: evaluate say_after('hello', 1) — creates a coroutine object
# Line 2: await that coroutine — THIS yields control
await say_after('hello', 1)
When main() hits await say_after(...), it yields. At this moment:
main()suspends- The event loop regains control
- The event loop now schedules
say_afterto run (it creates another Task for it)
Step 4: say_after runs
The event loop drives say_after. It hits await asyncio.sleep(delay) — this is where the real magic happens. asyncio.sleep() creates a Future that fires after delay seconds and yields to the event loop. The event loop registers a timer and moves on.
While say_after is “sleeping”, the event loop has nothing to do — so it runs the second say_after concurrently:
await say_after('world', 0.5) # This also runs concurrently with the first
Step 5: Sleeps complete, execution resumes
When the 0.5s sleep finishes first, the event loop resumes that task and prints "world". Half a second later, the 1s sleep finishes and "hello" prints. This is what the output looks like:
world
hello
Step 6: main() completes
Once both say_after coroutines have completed, main()‘s await expressions resolve, and main() can finish. The event loop exits.
The Awaitable Protocol
You might be wondering: how does await know how to suspend and resume? The answer is the awaitable protocol.
Any object can be awaited if it implements __await__. This method must return an iterator. When you write await something, Python calls something.__await__() and drives the resulting iterator using .send() — the same mechanism that drives generators.
For native coroutines (async def), __await__ returns the coroutine object itself. The event loop drives coroutines by calling coro.send(None). This is why you cannot await a regular function — it does not have __await__, and calling it executes immediately with no way to suspend.
Note: The event loop uses
coro.send(None)to drive coroutines — this is an implementation detail. The conceptual takeaway is thatawaitalways delegates to an iterator returned by__await__.
Why This Matters: Cooperative Scheduling
Here is the critical insight that separates async from threads: only one coroutine runs at a time.
The event loop runs on a single thread. It switches between coroutines only when a coroutine explicitly yields — via await, asyncio.sleep(), or an awaitable that suspends. This is cooperative multitasking.
Compare to threads: the OS forcibly preempts threads at any point (timer interrupts). Cooperative scheduling means your code must choose to yield. If you write:
async def cpu_bound():
result = heavy_computation() # No await — this blocks the event loop!
return result
You have just blocked the entire event loop. That is why asyncio provides asyncio.to_thread() for CPU-bound work — it runs the computation in a separate thread, freeing the event loop to continue.
This is also why async Python does not give you parallelism for CPU-bound work. The GIL still applies. But for I/O-bound work — network requests, file reads, database queries — async shines because you are not paying the cost of thread context switches.
Common Misconceptions
- “async means parallel” — No.
asyncmeans concurrency (interleaved execution), not parallelism (simultaneous execution). One thing happens at a time, but you are not sitting idle waiting. - “await blocks” — The opposite.
awaitsuspends the current coroutine and gives control back to the event loop, allowing other coroutines to run. - “async functions run in the background” — They do not run at all until something drives them.
asyncio.run(),create_task(), or explicitawaitis what triggers execution. - “async is just syntactic sugar” — Native coroutines (
async def) are a distinct type from generators. The@asyncio.coroutinedecorator used in Python 3.4 was syntactic sugar over generators.async defintroduced a fundamentally new object type with different semantics.
When to Use async/await
Use async when:
- You are building I/O-bound services (HTTP servers, database clients, file operations)
- You want to handle thousands of concurrent connections efficiently
- You need readable, sequential-looking code for asynchronous operations
Stick with synchronous code when:
- Your work is CPU-bound (use
concurrent.futuresormultiprocessinginstead) - You are writing scripts or simple scripts where async overhead is not worth it
- You are working with libraries that do not support async
Summary and Next Steps
You now understand the machinery behind Python’s async/await. Every time you write async def and reach for await, here is what is really happening:
async defcreates a coroutine object, not a generator- The coroutine is lazily evaluated — it needs a driver (event loop) to execute
awaitsuspends the coroutine and transfers control to the event loop via the awaitable protocol- Cooperative scheduling means the event loop runs on a single thread and only switches when coroutines explicitly yield
Next, explore how to build real async applications:
- How to Build a Modern Python API in 2025: FastAPI vs. Flask vs. Django Rest Framework — explore async-capable web frameworks
- uv Python Package Manager: Replace pip Forever — install async libraries faster
- Python asyncio documentation — official reference for
asyncio,Task,Future, and more
Further Reading
- Python asyncio documentation: A Conceptual Overview — official deep-dive into asyncio’s design
- PEP 492 — Coroutines with async and await syntax — the PEP that introduced native coroutines in Python 3.5
- David Beazley’s “Concurrency from the Ground Up” — a legendary talk on the challenges of async programming
- asyncio Tasks and Coroutines — official documentation on
Task,create_task(), and event loop behavior

