New in 2026: Master Python for AI, Data Science

Python

How Python’s async/await Works Under the Hood — A Deep Dive

Python async/await deep dive — how async/await works under the hood, generators, coroutines, and the event loop explained.

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 yield to async def
  • Understand exactly what async def creates — 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

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 def returns)
  • 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 Task wrapping the main() 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_after to 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 that await always 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. async means concurrency (interleaved execution), not parallelism (simultaneous execution). One thing happens at a time, but you are not sitting idle waiting.
  • “await blocks” — The opposite. await suspends 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 explicit await is what triggers execution.
  • “async is just syntactic sugar” — Native coroutines (async def) are a distinct type from generators. The @asyncio.coroutine decorator used in Python 3.4 was syntactic sugar over generators. async def introduced 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.futures or multiprocessing instead)
  • 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 def creates a coroutine object, not a generator
  • The coroutine is lazily evaluated — it needs a driver (event loop) to execute
  • await suspends 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:


Further Reading

Related posts
Python

Pydantic Agent Basics: A Complete 2026 Tutorial

ProgrammingPython

Production-Ready MCP Servers — Security, Testing & Deployment

ProgrammingPython

Build Your First MCP Server with Python SDK — Fundamentals

ProgrammingPython

Connect FastAPI to MCP — Two Integration Patterns

Leave a Reply