5.15. Async Programming
Table of Contents
Why Async Programming Matters for Backend Development
When you build backends, most of the time the bottleneck is not CPU, but waiting for external systems, for example:
- Waiting for a database query to finish
- Waiting for an HTTP call to another service
- Waiting for a file to be read from disk
During this waiting time, your program can either:
- Block and do nothing, or
- Continue doing useful work for other requests
Asynchronous programming is about writing code that can pause while it waits for I/O, and resume later, so the same process can handle many things at once.
In Python backends, this is especially important when you build servers with frameworks like FastAPI or when you call many external APIs.
Async programming lets one process handle many I/O bound tasks by not blocking while it waits. It does not magically make CPU bound code faster.
Synchronous vs Asynchronous: The Core Idea
Synchronous (Blocking) Code
In classic Python code, each line waits for the previous one to finish:
import time
def fetch_user():
print("Fetching user...")
time.sleep(2) # simulating slow I/O
print("User fetched")
return {"id": 1}
def fetch_orders():
print("Fetching orders...")
time.sleep(3) # simulating slow I/O
print("Orders fetched")
return [{"id": 101}, {"id": 102}]
def main():
user = fetch_user()
orders = fetch_orders()
print("Done")
main()Timeline:
- 0–2 seconds: waiting for user
- 2–5 seconds: waiting for orders
- Total: about 5 seconds
During each sleep, the program is doing nothing, just blocking.
Asynchronous (Non‑Blocking) Code
With async, you can start both operations, then await them:
import asyncio
async def fetch_user():
print("Fetching user...")
await asyncio.sleep(2)
print("User fetched")
return {"id": 1}
async def fetch_orders():
print("Fetching orders...")
await asyncio.sleep(3)
print("Orders fetched")
return [{"id": 101}, {"id": 102}]
async def main():
user_task = asyncio.create_task(fetch_user())
orders_task = asyncio.create_task(fetch_orders())
user = await user_task
orders = await orders_task
print("Done")
asyncio.run(main())Timeline:
- 0–2 seconds: both tasks run, both can pause and yield time
- 2–3 seconds: only orders is still running
- Total: about 3 seconds, not 5
Because while fetch_user is waiting, fetch_orders can use the same event loop.
The Event Loop and `async` / `await`
What Is the Event Loop?
The event loop is the core piece that:
- Tracks which async functions are currently running
- Switches between them when they
awaitsomething - Resumes them when the awaited operation is done
You usually do not manage the loop directly. Frameworks like FastAPI or libraries like asyncio.run handle it.
In a backend server, the event loop continuously:
- Accepts new incoming connections.
- Passes requests to your async endpoint functions.
- Pauses and resumes them as they await I/O.
Declaring Async Functions
An async function is defined with async def:
async def say_hello():
return "Hello"You cannot call it like a normal function if you want its result. This is coroutine, not a plain value.
result = say_hello()
print(result) # <coroutine object say_hello at 0x...>
To actually run it, you must await it from inside another async function, or use asyncio.run at the top level.
async def main():
result = await say_hello()
print(result) # "Hello"
import asyncio
asyncio.run(main())Rule:
- Use
async defto define asynchronous functions. - Use
awaitto pause and wait for an async operation inside an async function. - You cannot use
awaitat the top level of a regular script.
`await` Makes the Function Pause, not Block the Thread
When you write:
await asyncio.sleep(1)You are saying:
I am waiting for 1 second, but during this time, the event loop is free to run other tasks.
If you call the blocking time.sleep(1) inside async code, you freeze the whole loop for 1 second and block other requests. That is a common mistake.
Basic `asyncio` Building Blocks
asyncio is the standard library module for asynchronous programming in Python.
Running an Async Program
This pattern is common in simple scripts:
import asyncio
async def main():
print("Before")
await asyncio.sleep(1)
print("After")
if __name__ == "__main__":
asyncio.run(main())
In a web backend framework, you usually do not call asyncio.run yourself. The framework does something similar internally.
Sequential vs Concurrent `await`
Two awaits in a row are still sequential:
async def main():
await asyncio.sleep(2) # first wait 2s
await asyncio.sleep(3) # then wait 3s
# total ~5s
To run them concurrently, you can use asyncio.gather:
async def main():
await asyncio.gather(
asyncio.sleep(2),
asyncio.sleep(3),
) # total ~3sOr create tasks:
async def main():
task1 = asyncio.create_task(asyncio.sleep(2))
task2 = asyncio.create_task(asyncio.sleep(3))
await task1
await task2Both ways let the event loop juggle multiple operations at once.
Example: Calling Multiple APIs in Parallel
Imagine a backend that needs:
- User data from
https://api.example.com/user/1 - Orders from
https://api.example.com/user/1/orders
With asynchronous HTTP client httpx:
import asyncio
import httpx
async def fetch_user(client):
resp = await client.get("https://api.example.com/user/1")
return resp.json()
async def fetch_orders(client):
resp = await client.get("https://api.example.com/user/1/orders")
return resp.json()
async def main():
async with httpx.AsyncClient() as client:
user_task = asyncio.create_task(fetch_user(client))
orders_task = asyncio.create_task(fetch_orders(client))
user, orders = await asyncio.gather(user_task, orders_task)
print(user)
print(orders)
asyncio.run(main())A web API endpoint can use the same pattern to respond faster.
Async I/O vs CPU Bound Work
Async Shines for I/O Bound Work
I/O bound means your program spends most of its time waiting for:
- Database queries
- Network calls
- Disk operations
Async code can handle many such operations at once in a single process.
Example: A FastAPI server that:
- Awaits database queries
- Awaits calls to third party APIs
The same process can serve many concurrent requests.
Async Does Not Speed Up CPU Heavy Code
CPU bound work consumes CPU directly, for example:
- Image processing
- Complex data analysis
- Compression or encryption loops
Async does not give your CPU more power. This function:
def slow_cpu_work():
total = 0
for i in range(10_000_000):
total += i
return total
If you simply put async in front:
async def slow_cpu_work():
total = 0
for i in range(10_000_000):
total += i
return total
It is still CPU bound and will block the event loop until it finishes, because there is no await inside.
To handle CPU heavy work in a backend, you usually:
- Move it to a background worker (see background processing chapters).
- Or run it in a separate process or thread pool from async code.
Async programming improves concurrency for I/O bound tasks, not raw CPU speed. Do not expect async to speed up heavy calculations.
Mixing Async and Sync Code Safely
In real projects you often need to use:
- Async frameworks and libraries.
- Legacy sync libraries (for example a database driver).
Calling Synchronous Code from Async
If you call a blocking function directly from async code, it will freeze the event loop:
import time
async def handler():
time.sleep(2) # BAD inside async code
return "Done"
Use asyncio.to_thread to run blocking code in a thread pool:
import asyncio
import time
def blocking_task():
time.sleep(2)
return "Done"
async def handler():
result = await asyncio.to_thread(blocking_task)
return resultThis is very useful when you integrate a library that has no async version.
Example: Synchronous File Read in Async Code
Synchronous version:
def read_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()Async safe wrapper:
import asyncio
async def read_file_async(path: str) -> str:
return await asyncio.to_thread(read_file, path)
You can then use read_file_async in async endpoints.
Async in Web Backends: Conceptual View
You will see async again in the chapters about FastAPI and asynchronous endpoints. Here is how it fits conceptually.
Async Endpoints
In an async capable framework, an endpoint can look like this:
# conceptual example, not tied to a specific framework
async def get_user_handler(request):
user_id = request.path_params["user_id"]
user = await fetch_user_from_db(user_id) # async DB call
orders = await fetch_orders_for_user(user_id) # async DB call
return {
"user": user,
"orders": orders,
}
While fetch_user_from_db and fetch_orders_for_user wait for the database, the event loop can process other incoming requests.
If you have 100 concurrent requests, the server can interleave their work instead of blocking on each one.
When to Use Async in Backends
Some simple backends work fine with synchronous code. Async is especially helpful when:
- You expect high concurrency (many users at the same time).
- Your app does a lot of networking on each request (calls other services, APIs).
- You want to keep resource usage low for many open connections.
If your backend is small and simple, synchronous code may be easier to reason about. Use async when you start to hit scalability or latency issues, or when you use frameworks that are designed for async (like FastAPI).
Common Pitfalls and Best Practices
Pitfall 1: Using Blocking Libraries in Async Code
Example of a blocking HTTP client in async code:
import requests # blocking
import asyncio
async def fetch_data():
response = requests.get("https://example.com") # blocks the loop
return response.textBetter: use an async client:
import httpx
import asyncio
async def fetch_data():
async with httpx.AsyncClient() as client:
response = await client.get("https://example.com")
return response.text
If you must use a blocking library, wrap it in asyncio.to_thread.
Pitfall 2: Forgetting to `await`
If you forget await, the coroutine is created but not executed:
async def say_hi():
print("Hi")
async def main():
say_hi() # missing await, nothing runs
import asyncio
asyncio.run(main())Correct:
async def main():
await say_hi()Pitfall 3: Overusing Concurrency
Creating too many tasks can overload external systems.
Example of risky code:
async def fetch_all(urls):
tasks = [
asyncio.create_task(fetch_single(url))
for url in urls
]
return await asyncio.gather(*tasks)
If urls has thousands of entries, you might:
- Open thousands of connections.
- Overload the remote server or your own machine.
Better: limit concurrency with a semaphore:
import asyncio
semaphore = asyncio.Semaphore(10) # allow 10 at a time
async def fetch_single_limited(url):
async with semaphore:
return await fetch_single(url)Pitfall 4: Mixing Async and Sync Frameworks Incorrectly
Trying to call async code from a synchronous web framework (like a classic WSGI framework) or the other way around can be tricky and inefficient. In practice, choose:
- An async capable framework for async code.
- Or use sync code with sync frameworks.
Practical Mini Exercises (Mentally or in a REPL)
Try these small tasks to become familiar with async patterns.
Exercise 1: Convert Sync to Async
- Write a synchronous function that uses
time.sleep(1)three times in a loop and prints a counter. - Convert it to async using
asyncio.sleep(1). - Run it with
asyncio.run.
Exercise 2: Parallel Sleep
Write an async function:
async def wait_and_print(seconds, name):
await asyncio.sleep(seconds)
print(f"{name} done after {seconds} seconds")
Then run three of them concurrently with asyncio.gather and observe that the total time is close to the maximum of the seconds, not their sum.
Exercise 3: Wrap Blocking Code
Create a blocking function that reads a large file with open and read. Then:
- Wrap it with
asyncio.to_thread. - Create multiple async tasks that read the file in parallel.
- Print how long it takes.
Summary
- Async programming in Python uses
async def,await, and an event loop to handle many I/O bound tasks concurrently. - It is especially valuable in backend development where requests often wait for databases, other services, or the network.
- Use
awaitinside async functions to pause and free the event loop to serve other tasks. - Use async capable libraries (for example
httpx.AsyncClient) and avoid blocking calls in async code, or wrap them withasyncio.to_thread. - Async improves concurrency and scalability for I/O bound workloads, not raw CPU performance.
In later chapters, when you work with FastAPI and background tasks, you will apply these async concepts directly to real backend applications.
Views: 6
KAHIBARO