The short version
If you just want the mental model:
async defdefines a coroutine function. Calling it doesn’t run anything — it returns a coroutine object, which is a description of work, not the work itself.awaitpauses the current coroutine and hands control back to the event loop, which is then free to run something else until the awaited thing finishes.- The event loop is a single-threaded scheduler. There is no parallelism in async Python. It’s very efficient turn-taking, and the turns only change at an
await. - Async helps when you’re I/O-bound — waiting on networks, APIs, databases, disks. It does nothing for CPU-bound work; that’s what
multiprocessingis for. - One blocking call stalls everything. A
time.sleep()or a synchronous DB driver anywhere in a coroutine freezes the entire loop, not just that coroutine.
The rest of this post is about why each of those is true, and what’s happening underneath.
Why async Python exists
Modern applications — especially anything dealing with network requests or many concurrent operations — need to handle multiple things at once. For a long time, threading and multiprocessing were the go-to answers in Python. But the Global Interpreter Lock and the overhead of thread management limit how well those approaches work, particularly for I/O-bound workloads where the CPU is mostly sitting idle waiting on something external.
Async is the alternative. The surface syntax of async and await looks simple enough, but understanding what’s happening beneath it is what separates working async code from code that mysteriously runs no faster than the synchronous version. We’ll cover the event loop, coroutines, tasks, futures, context switching, and how async compares to threads and processes.
async def and await: the syntax
These two keywords are the building blocks for writing asynchronous code in Python.
What async def actually does
In Python, you declare a function as asynchronous by using the async def syntax instead of the regular def. This seemingly small change has profound implications. An async def function, also known as a coroutine function, doesn’t execute like a regular synchronous function. Instead, it returns a coroutine object.
Think of a coroutine object as a promise of work to be done later. It’s not the work itself, but rather a representation of that work, ready to be executed when the time is right.
1import asyncio
2
3async def hello_async():
4 print("Hello from async function!")
5 await asyncio.sleep(1) # Simulate some async operation (like network I/O)
6 print("Async function finished.")
7
8# Calling the async function returns a coroutine object
9coro = hello_async()
10print(f"Coroutine object: {coro}")
11
12# To actually run the coroutine, we need to use an event loop
13asyncio.run(coro)
In this example, hello_async is an asynchronous function. When we call hello_async(), it doesn’t immediately print “Hello from async function!”. Instead, it creates and returns a coroutine object. To actually execute the code within the coroutine, we need to use asyncio.run(), which sets up and runs an event loop (more on event loops shortly) to manage the execution of our coroutine.
What await actually does
The await keyword is the other half of the async duo. It can only be used inside async def functions. await is the point where an asynchronous function can pause its execution and yield control back to the event loop. This is the crucial mechanism that enables concurrency in async Python without relying on threads.
When you await something, you are essentially saying: “I need to wait for this asynchronous operation to complete. While I’m waiting, I’m going to yield control back to the event loop so it can work on other tasks. Once this operation is done, please resume my execution from right here.”
In our hello_async example, await asyncio.sleep(1) is a simulated asynchronous operation that represents waiting for 1 second. During this second, the hello_async coroutine pauses, and the event loop is free to execute other coroutines or handle other events. Once the sleep duration is over, the event loop resumes the hello_async coroutine from the line after the await statement, and it prints “Async function finished.”
Important Note: You can only await objects that are awaitable. In practice, this usually means you’re awaiting other coroutines, Future objects (which we’ll discuss later), or objects that have implemented the __await__ special method. Standard synchronous functions are not awaitable.
A complete async Python example
Here’s the thing the syntax alone doesn’t show you: await on its own doesn’t buy you any concurrency. If you await three things one after another, you wait for all three in sequence — exactly like synchronous code, just with extra keywords.
1import asyncio
2import time
3
4async def fetch(name, seconds):
5 print(f"{name}: starting")
6 await asyncio.sleep(seconds) # stands in for a network call
7 print(f"{name}: done")
8 return f"{name} result"
9
10async def sequential():
11 start = time.perf_counter()
12 await fetch("A", 1)
13 await fetch("B", 1)
14 await fetch("C", 1)
15 print(f"sequential took {time.perf_counter() - start:.1f}s")
16
17async def concurrent():
18 start = time.perf_counter()
19 results = await asyncio.gather(
20 fetch("A", 1),
21 fetch("B", 1),
22 fetch("C", 1),
23 )
24 print(f"concurrent took {time.perf_counter() - start:.1f}s")
25 return results
26
27asyncio.run(sequential()) # sequential took 3.0s
28asyncio.run(concurrent()) # concurrent took 1.0s
Same coroutines, same await, 3x difference. The concurrency comes from asyncio.gather() handing the event loop three coroutines at once, so it can start all three and then sit on the await while every one of them is in flight. In the sequential version the loop only ever knows about one at a time.
This is the single most common mistake in async Python: writing await in a loop and wondering why nothing got faster.
1# Slow — one at a time, despite being "async"
2results = []
3for url in urls:
4 results.append(await fetch_url(url))
5
6# Fast — all in flight at once
7results = await asyncio.gather(*(fetch_url(url) for url in urls))
One more trap worth knowing early. Because the event loop is single-threaded, a blocking call inside a coroutine doesn’t just block that coroutine, it blocks everything:
1async def bad():
2 time.sleep(1) # blocks the entire event loop for a full second
3
4async def good():
5 await asyncio.sleep(1) # yields control; other coroutines run
The same applies to any synchronous library — a non-async database driver, requests instead of aiohttp, a heavy CPU computation. If you must call blocking code, push it off the loop with asyncio.to_thread().
The asyncio event loop
At the heart of async Python lies the event loop. Think of the event loop as the central conductor of an orchestra. It’s responsible for managing and scheduling the execution of all your asynchronous tasks. It’s a single-threaded loop that constantly monitors for events and dispatches tasks to be executed when those events occur.
How the event loop works
- Task Queue: The event loop maintains a queue of tasks (usually coroutines wrapped in
Taskobjects) that are ready to be executed or resumed. - Event Monitoring: The event loop also monitors for various events, such as network sockets becoming ready for reading or writing, timers expiring, or file operations completing. It typically uses efficient system calls like
select,poll, orepoll(depending on the operating system) to monitor these events without blocking. - Task Execution and Resumption: When an event occurs that makes a task ready to proceed (e.g., data is available on a socket that a task is waiting to read from), the event loop picks up that task from the queue and executes it until it encounters an
awaitstatement. - Yielding Control with
await: When a coroutine reaches anawaitstatement, it effectively tells the event loop, “I need to wait for this operation. Please pause me and let someone else run.” The event loop then takes control and looks for other tasks in the queue that are ready to run. - Resuming Execution: Once the awaited operation completes (e.g., the network request returns, the timer expires), the event loop is notified. It then puts the paused coroutine back into the task queue, ready to be resumed at the point where it left off.
- Looping Continuously: The event loop continues this process of monitoring events, executing tasks, and pausing/resuming coroutines in a loop until there are no more tasks to run or the program is explicitly stopped.
asyncio, uvloop, and other event loop implementations
Python’s standard library provides the asyncio module, which includes a built-in event loop implementation. This default event loop is written in Python and is generally sufficient for many use cases.
However, for performance-critical applications, especially those dealing with high-performance networking, you might consider using alternative event loop implementations. One popular option is uvloop.
uvloop: uvloop is a blazing-fast, drop-in replacement for asyncio’s event loop. It’s written in Cython and built on top of libuv, the same high-performance library that powers Node.js. uvloop is significantly faster than the default asyncio event loop, especially for network I/O.
To use uvloop, you typically need to install it separately (pip install uvloop) and then set it as the event loop policy for asyncio when your application starts:
1import asyncio
2import uvloop
3
4async def main():
5 print("Running with uvloop!")
6 await asyncio.sleep(1)
7 print("Done.")
8
9if __name__ == "__main__":
10 uvloop.install() # Set uvloop as the event loop policy
11 asyncio.run(main())
Other event loop implementations exist, but asyncio and uvloop are the most commonly used in Python async programming. Choosing between them often depends on the performance requirements of your application. For most general async tasks, asyncio’s default loop is perfectly adequate. For high-load network applications, uvloop can provide a noticeable performance boost.
Coroutines, tasks, and futures
To truly understand async Python, it’s helpful to think about it in layers. We’ve already touched upon coroutines and the event loop. Let’s now delve into the roles of Tasks and Futures.
Coroutines
As we discussed earlier, coroutines are the asynchronous functions you define using async def. They represent units of asynchronous work. Coroutines themselves are not directly executed by the event loop. Instead, they need to be wrapped in something that the event loop can manage and schedule. This “something” is a Task.
Tasks: running coroutines concurrently
A Task in asyncio is essentially a wrapper around a coroutine that allows the event loop to schedule and manage its execution. When you want to run a coroutine concurrently within the event loop, you typically create a Task from it.
You can create a Task using asyncio.create_task():
1import asyncio
2
3async def my_coroutine():
4 print("Coroutine started")
5 await asyncio.sleep(2)
6 print("Coroutine finished")
7 return "Coroutine result"
8
9async def main():
10 task1 = asyncio.create_task(my_coroutine()) # Create a Task from the coroutine
11 task2 = asyncio.create_task(my_coroutine()) # Create another Task
12
13 print("Tasks created, waiting for completion...")
14
15 result1 = await task1 # Await the completion of task1
16 result2 = await task2 # Await the completion of task2
17
18 print(f"Task 1 result: {result1}")
19 print(f"Task 2 result: {result2}")
20
21asyncio.run(main())
In this example, we create two Tasks, task1 and task2, from the same my_coroutine. asyncio.create_task() schedules these coroutines to be run by the event loop concurrently. When we await task1 and await task2, we are waiting for these Tasks to complete and retrieve their results.
Tasks are essential for managing the lifecycle of coroutines within the event loop. They provide methods to:
- Cancel a Task:
task.cancel() - Check if a Task is done:
task.done() - Get the result of a Task:
task.result()(if done) - Get exceptions raised during Task execution:
task.exception()(if any)
Futures: results that haven’t arrived yet
A Future is an object that represents the eventual result of an asynchronous operation. It’s a placeholder for a value that might not be available yet. Tasks in asyncio are actually a subclass of Futures.
Futures are used extensively in async Python to represent the outcome of operations that are performed asynchronously, such as:
- Network I/O: Reading data from a socket, sending a request to a server.
- File I/O: Reading or writing to a file (in an async context).
- Concurrent computations: Tasks running in parallel (within the same event loop or in different threads/processes).
A Future object has a state that can be:
- Pending: The asynchronous operation is still in progress.
- Running: The operation is currently being executed.
- Done: The operation has completed successfully or with an exception.
- Cancelled: The operation has been cancelled.
You can interact with a Future to:
- Check if it’s done:
future.done() - Get the result:
future.result()(blocks until done if pending, raises exception if an exception occurred) - Get exceptions:
future.exception()(returns exception if one occurred, otherwiseNone) - Add callbacks:
future.add_done_callback(callback_function)(run a function when the future is done) - Cancel the future:
future.cancel()
When you await a Task (or any awaitable Future-like object), you are essentially waiting for that Future to become “done” and then retrieving its result or handling any exceptions.
How coroutines pause and resume
Now let’s look at how coroutines are actually executed, and how context switching works in async Python.
Cooperative vs. preemptive multitasking
Async Python uses cooperative multitasking. This is in contrast to preemptive multitasking used by operating systems for threads and processes.
- Preemptive Multitasking (Threads/Processes): In preemptive multitasking, the operating system’s scheduler decides when to switch between threads or processes. It can interrupt a running thread/process at any time and switch to another, even if the running thread/process doesn’t explicitly yield control. This is typically based on time slices and priority levels.
- Cooperative Multitasking (Async Python): In cooperative multitasking, coroutines voluntarily yield control back to the event loop when they encounter an
awaitstatement. The event loop then decides which coroutine to run next. Context switching only happens at these explicitawaitpoints. A coroutine will continue to run until it reaches anawaitor completes.
This cooperative nature has important implications:
- No True Parallelism (within a single event loop): Within a single event loop running in a single thread, true parallelism is not achieved. Coroutines take turns running. If a coroutine doesn’t
awaitfrequently and performs long-running CPU-bound operations, it can block the event loop and prevent other coroutines from making progress. - Responsiveness: Cooperative multitasking is excellent for I/O-bound tasks. While one coroutine is waiting for I/O, another can run, keeping the application responsive.
- Less Overhead: Context switching in cooperative multitasking is generally lighter than preemptive context switching between threads or processes. There’s less operating system overhead involved.
- Deterministic Behavior (mostly): Because context switching happens only at explicit
awaitpoints, the execution flow of async code is often more predictable and easier to reason about compared to multithreaded code, which can have race conditions and unpredictable scheduling.
What happens at an await
When a coroutine reaches an await statement, several things happen:
awaitExpression: The expression afterawait(e.g.,asyncio.sleep(1), another coroutine, a Future) must be awaitable.- Yielding Control: The coroutine effectively “pauses” its execution at the
awaitpoint. It returns control back to the event loop. - Event Loop Takes Over: The event loop becomes active again. It looks at its task queue for other tasks that are ready to run.
- Registering for Resumption: The coroutine, along with information about where it paused (the line after the
await), is registered with the event loop as being “waiting” for the completion of the awaited operation. - Awaited Operation Proceeds: The awaited operation (e.g., network request, timer) proceeds asynchronously in the background (often managed by non-blocking system calls).
- Event Notification: When the awaited operation is complete, the event loop receives a notification (e.g., socket becomes readable, timer expires).
- Resuming the Coroutine: The event loop puts the paused coroutine back into the task queue, marked as ready to be resumed.
- Coroutine Resumes: When the event loop gets around to executing this coroutine again, it resumes from the exact point where it was paused (right after the
awaitstatement). It now has access to the result of the awaited operation (if any).
This pause-and-resume mechanism is what allows asynchronous code to be written in a seemingly sequential style, even though it’s actually being executed in an interleaved and non-blocking manner.
Async vs. threads vs. multiprocessing in Python
It’s worth being clear about when async is the right choice and when threading or multiprocessing is the better fit. They solve different problems, and reaching for the wrong one is how you end up with async code that runs no faster than the version you replaced.
When to use async: I/O-bound work
Async Python excels in scenarios where your application is I/O-bound. This means that the primary bottleneck is waiting for external operations to complete, such as:
- Network requests: Fetching data from APIs, making HTTP requests, communicating with databases over a network.
- File I/O: Reading and writing to files (especially over a network file system).
- Waiting for user input: In GUI applications or interactive systems.
In these cases, the CPU is often idle while waiting for I/O operations. Async Python allows you to utilize this idle time by letting other coroutines run while one is waiting for I/O. It’s highly efficient for handling many concurrent I/O operations with minimal overhead.
Example: Web Server
A web server that handles many concurrent requests is a classic example where async Python shines. While one request is being processed (which often involves waiting for database queries, external API calls, etc.), the server can be handling other requests concurrently.
1import asyncio
2import aiohttp
3from aiohttp import web
4
5async def fetch_data_from_api(api_url):
6 async with aiohttp.ClientSession() as session:
7 async with session.get(api_url) as response:
8 return await response.json()
9
10async def handler(request):
11 data = await fetch_data_from_api("https://api.example.com/data")
12 return web.json_response(data)
13
14async def main():
15 app = web.Application()
16 app.add_routes([web.get('/', handler)])
17 runner = web.AppRunner(app)
18 await runner.setup()
19 site = web.TCPSite(runner, 'localhost', 8080)
20 await site.start()
21 print("Server started at http://localhost:8080")
22 await asyncio.Event().wait() # Keep the server running indefinitely
23
24if __name__ == "__main__":
25 asyncio.run(main())
This example uses aiohttp, an async HTTP client and server library. The fetch_data_from_api coroutine performs an asynchronous HTTP request. The handler coroutine uses await fetch_data_from_api to fetch data without blocking the server. The server can handle many requests concurrently, making it highly scalable for I/O-bound web applications.
When to use threads
Threads, especially when used with Python’s threading module, are suitable for tasks that are more CPU-bound and can benefit from concurrency (even if not true parallelism due to the GIL).
CPU-Bound Tasks: Tasks that spend most of their time performing computations on the CPU, rather than waiting for I/O. Examples include:
- Image processing
- Numerical computations
- Data analysis
- Cryptographic operations
Concurrency (with GIL limitations): Python’s GIL (Global Interpreter Lock) prevents true parallelism for CPU-bound tasks in standard CPython threads. Only one thread can hold the Python interpreter lock at any given time. However, threads can still provide concurrency by releasing the GIL during I/O operations or certain blocking system calls. This can improve responsiveness even for CPU-bound tasks if they involve some I/O or blocking.
Example: CPU-Bound Computation (with threading for concurrency)
1import threading
2import time
3
4def cpu_bound_task(task_id):
5 print(f"Task {task_id} started")
6 start_time = time.time()
7 result = 0
8 for _ in range(10**7): # Simulate CPU-intensive computation
9 result += 1
10 end_time = time.time()
11 duration = end_time - start_time
12 print(f"Task {task_id} finished in {duration:.4f} seconds, result: {result}")
13
14def main_threads():
15 threads = []
16 for i in range(4):
17 thread = threading.Thread(target=cpu_bound_task, args=(i,))
18 threads.append(thread)
19 thread.start()
20
21 for thread in threads:
22 thread.join() # Wait for all threads to complete
23
24if __name__ == "__main__":
25 main_threads()
In this example, cpu_bound_task simulates a CPU-intensive operation. We create multiple threads to run this task concurrently. While the GIL limits true parallelism for CPU-bound Python code, threads can still provide some concurrency and potential performance improvement, especially if the tasks involve some I/O or blocking operations. For purely CPU-bound tasks, however, the benefits might be limited by the GIL.
When to use processes: true parallelism
For truly CPU-bound and computationally intensive tasks that need true parallelism and to bypass the GIL limitations, multiprocessing using Python’s multiprocessing module is the way to go.
- True Parallelism: Multiprocessing creates separate processes, each with its own Python interpreter and memory space. Processes run in parallel on multiple CPU cores, achieving true parallelism for CPU-bound tasks.
- Bypassing the GIL: Each process has its own GIL, so the GIL limitation of threads is overcome.
- Higher Overhead: Process creation and inter-process communication have more overhead compared to threads or async tasks. Processes consume more system resources (memory, process management overhead).
Example: CPU-Bound Computation (with multiprocessing for parallelism)
1import multiprocessing
2import time
3
4def cpu_bound_task_process(task_id): # Same CPU-bound task as before
5 print(f"Process {task_id} started")
6 start_time = time.time()
7 result = 0
8 for _ in range(10**7):
9 result += 1
10 end_time = time.time()
11 duration = end_time - start_time
12 print(f"Process {task_id} finished in {duration:.4f} seconds, result: {result}")
13
14def main_processes():
15 processes = []
16 for i in range(4):
17 process = multiprocessing.Process(target=cpu_bound_task_process, args=(i,))
18 processes.append(process)
19 process.start()
20
21 for process in processes:
22 process.join() # Wait for all processes to complete
23
24if __name__ == "__main__":
25 main_processes()
In this multiprocessing example, we create separate processes to run the same CPU-bound task. Because each process has its own interpreter and bypasses the GIL, we can achieve true parallelism and significantly speed up CPU-intensive computations on multi-core systems.
Choosing between async, threads, and processes
| Feature | Async Python (asyncio) | Threads (threading) | Processes (multiprocessing) |
|---|---|---|---|
| Task Type | I/O-bound | CPU-bound (with I/O) | CPU-bound (pure computation) |
| Parallelism | No (within loop) | Limited (due to GIL) | Yes (true parallelism) |
| Concurrency | High | Moderate | High |
| Overhead | Low | Moderate | High |
| GIL Impact | Not affected | Limited by GIL | Bypasses GIL |
| Context Switching | Cooperative (light) | Preemptive (OS) | Preemptive (OS) |
| Memory Footprint | Lower | Moderate | Higher |
| Complexity | Moderate | Moderate | Higher (IPC needed) |
| Use Cases | Web servers, network apps, UI | Concurrent I/O + CPU | CPU-intensive computations |
General Guidelines:
- I/O-Bound, High Concurrency: Async Python (asyncio) is often the best choice.
- CPU-Bound with some I/O, Responsiveness: Threads (threading) can be considered, but be mindful of GIL limitations for pure CPU-bound tasks.
- CPU-Bound, True Parallelism, Max Performance: Processes (multiprocessing) are essential, especially for computationally intensive tasks on multi-core machines.
- Hybrid Applications: You can combine async and multiprocessing. For example, use async for handling network I/O and multiprocessing for CPU-bound background tasks.
When to reach for async
Async Python is a good way to write concurrent code for I/O-bound applications, and understanding the machinery underneath — the event loop, coroutines, tasks, futures, cooperative multitasking — is what makes the difference between using it well and using it superstitiously.
It isn’t a silver bullet. It won’t speed up CPU-bound work, it doesn’t replace threading or multiprocessing, and a single blocking call will undo all of it. But for the case it’s designed for — lots of concurrent waiting on things outside your process — nothing else in Python comes close on overhead.
If you take three things from this post: async def returns a coroutine rather than running one, concurrency comes from gather and tasks rather than from await itself, and the loop is single-threaded so anything that blocks blocks everyone.
If you want to see these concepts in a real application, FastAPI is async Python end to end — I’ve written about integration testing FastAPI, where the event loop and async def shape how you have to structure your test fixtures.