Async/Await in Python: The Complete Introduction
Asynchronous programming (async) in Python allows you to write code that handles multiple I/O operations concurrently without blocking the main thread. When code waits for network responses, database queries, or file operations, async lets your program do other useful work during those waits. Python's asyncio library and async/await keywords provide a straightforward way to write concurrent code that's more efficient and scalable than traditional synchronous approaches.
Prerequisites
A solid understanding of Python functions, basic data types, and familiarity with concepts like generators and context managers is helpful.
The Problem: Why Traditional Synchronous Code Blocks
Traditional Python code runs synchronously—line by line, statement by statement. When your program encounters an operation that requires waiting (a network request, a database query), the entire program freezes until that operation completes.
Synchronous Example: Three Web Requests
import time
def download_page(url):
print(f"Starting download: {url}")
# Simulate a network request taking 2 seconds
time.sleep(2)
print(f"Finished download: {url}")
# Run three downloads sequentially
start = time.time()
download_page("https://example.com/page1")
download_page("https://example.com/page2")
download_page("https://example.com/page3")
end = time.time()
print(f"Total time: {end - start:.2f} seconds")
Output:
Starting download: https://example.com/page1
Finished download: https://example.com/page1
Starting download: https://example.com/page2
Finished download: https://example.com/page2
Starting download: https://example.com/page3
Finished download: https://example.com/page3
Total time: 6.00 seconds
Each download takes 2 seconds, and they run one after another. Total time: 6 seconds. The program spends all that time idle, waiting for network responses. The CPU isn't busy—it's just blocked.
This is called an I/O-bound problem: the bottleneck is Input/Output (network, disk), not the CPU's processing power. Asynchronous programming solves this by allowing concurrent operations.
When to Use Async: I/O-Bound vs. CPU-Bound
The decision to use async depends on your program's bottleneck.
I/O-Bound Tasks: Perfect for Async
Use asyncio when your program spends most of its time waiting for I/O:
- Network requests: Web scraping, API calls, downloading files
- Database queries: Fetching data from PostgreSQL, MongoDB, etc.
- File operations: Reading/writing large files, disk-bound work
- Interprocess communication: Message queues, microservice calls
In these cases, async dramatically improves performance because it runs other tasks while waiting.
CPU-Bound Tasks: Async Won't Help
Do NOT use asyncio for computationally intensive work:
- Mathematical calculations: Complex algorithms, simulations
- Data processing: Machine learning inference, image encoding
- Video transcoding: Encoding, compression tasks
Why? asyncio runs on a single CPU thread. It can't parallelize CPU work across cores. For CPU-bound tasks, use Python's multiprocessing module or libraries like concurrent.futures to distribute work across multiple cores.
| Category | Example | Use Asyncio? | Alternative |
|---|---|---|---|
| I/O-Bound | 100 API calls | Yes | Thread pool |
| I/O-Bound | Reading 1 GB file | Yes | Thread pool |
| CPU-Bound | Sorting 1M items | No | multiprocessing |
| CPU-Bound | Image encoding | No | Thread pool + CPU binding |
| Mixed | Download + encode | Both | asyncio + multiprocessing |
Core Async Concepts: async, await, and Event Loops
Python's asyncio library provides three key components:
1. Coroutines with async def
A coroutine is a special function that can be paused and resumed. Define it using async def:
async def greet(name):
print(f"Hello, {name}!")
return f"Greeting sent to {name}"
# Creating a coroutine (doesn't run it yet)
coro = greet("Alice")
print(coro) # Output: <coroutine object greet at 0x...>
Calling an async def function returns a coroutine object—it doesn't execute immediately. To run it, you need an event loop.
2. The await Keyword
await pauses a coroutine until an awaitable task completes, freeing the event loop to run other tasks.
import asyncio
async def fetch_data(url):
print(f"Fetching {url}")
# Simulate a 2-second network delay
await asyncio.sleep(2)
print(f"Received data from {url}")
return f"data from {url}"
async def main():
# Using await pauses the current coroutine
result = await fetch_data("https://api.example.com")
print(result)
asyncio.run(main())
Output:
Fetching https://api.example.com
Received data from https://api.example.com
data from https://api.example.com
The await asyncio.sleep(2) pauses the coroutine for 2 seconds. If you were running multiple coroutines, other tasks would execute during this pause.
3. The Event Loop with asyncio.run()
An event loop is a scheduler that manages and executes coroutines. asyncio.run(main_coroutine) starts the loop and runs your async code.
Running Multiple Tasks Concurrently
The real power of async is running multiple I/O operations concurrently. Use asyncio.gather() to run multiple coroutines at the same time.
Concurrent Downloads Example
import asyncio
import time
async def download_page(url):
print(f"Starting: {url}")
# Simulate network delay
await asyncio.sleep(2)
print(f"Finished: {url}")
return f"content from {url}"
async def main():
start = time.time()
# Run all three downloads concurrently
results = await asyncio.gather(
download_page("https://example.com/page1"),
download_page("https://example.com/page2"),
download_page("https://example.com/page3")
)
end = time.time()
print(f"\nResults: {results}")
print(f"Total time: {end - start:.2f} seconds")
asyncio.run(main())
Output:
Starting: https://example.com/page1
Starting: https://example.com/page2
Starting: https://example.com/page3
Finished: https://example.com/page1
Finished: https://example.com/page2
Finished: https://example.com/page3
Results: ['content from https://example.com/page1', 'content from https://example.com/page2', 'content from https://example.com/page3']
Total time: 2.02 seconds
All three downloads start immediately and run in parallel. Total time is 2 seconds (not 6), because while one coroutine is waiting on asyncio.sleep(), the others execute. This is concurrency: multiple tasks running overlapped in time.
Creating Reusable Async Functions
Build a reusable async API client:
import asyncio
import aiohttp
async def fetch_json(session, url):
"""Fetch JSON from URL asynchronously."""
async with session.get(url) as response:
return await response.json()
async def fetch_multiple_apis(urls):
"""Fetch multiple API endpoints concurrently."""
async with aiohttp.ClientSession() as session:
tasks = [fetch_json(session, url) for url in urls]
return await asyncio.gather(*tasks)
# Usage
urls = [
"https://api.github.com/users/github",
"https://api.github.com/users/google",
"https://api.github.com/users/microsoft"
]
# Run concurrently
results = asyncio.run(fetch_multiple_apis(urls))
for user in results:
print(f"User: {user.get('name')}, Followers: {user.get('followers')}")
The async with statement (async context manager) safely manages the session, ensuring resources are cleaned up. All three API calls happen concurrently, saving time.
Common Async Patterns
Pattern 1: Wait for Multiple Tasks
async def main():
task1 = asyncio.create_task(fetch_data("url1"))
task2 = asyncio.create_task(fetch_data("url2"))
# Wait for both
data1, data2 = await asyncio.gather(task1, task2)
Pattern 2: Timeout
async def main():
try:
result = await asyncio.wait_for(fetch_data("url"), timeout=5.0)
except asyncio.TimeoutError:
print("Request timed out")
Pattern 3: Return First Completed
async def main():
# Return as soon as one completes
done, pending = await asyncio.wait(
[fetch_data("url1"), fetch_data("url2")],
return_when=asyncio.FIRST_COMPLETED
)
Key Takeaways
- Async/await is for I/O-bound tasks (network, database, files) where your program spends time waiting, not computing.
- Coroutines (
async def) are functions that can be paused and resumed; they don't run until awaited. awaitpauses a coroutine, allowing the event loop to run other tasks during the wait.asyncio.run()starts the event loop and executes your main coroutine.asyncio.gather()runs multiple coroutines concurrently, dramatically improving performance for I/O operations.- Do not use async for CPU-bound work; use
multiprocessingor thread pools instead.
Frequently Asked Questions
What's the difference between async/await and threading?
Threads are OS-level constructs that run in parallel on multiple cores (true parallelism). Each thread costs ~2 MB of memory. Async/await runs on a single thread but switches tasks efficiently when one awaits. Async is lighter: thousands can run concurrently on a single thread. Use async for I/O; use threads for CPU work.
Can I mix await with regular synchronous functions?
No. You can only await an awaitable (coroutines, futures, or objects with an __await__ method). If fetch_data() is a regular function (not async def), you can't await it. However, you can run sync functions in an executor if needed:
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, sync_function, arg)
What happens if I forget await?
async def main():
fetch_data("url") # BUG: forgot await
# Creates a coroutine but doesn't run it
# Python warns: "coroutine was never awaited"
Always await coroutines, or create tasks with asyncio.create_task().
Is async harder to debug than synchronous code?
Yes, somewhat. Debugging async code requires understanding the event loop and stack traces can be complex. Use asyncio debugging tools and logging; avoid mixing async/sync unnecessarily. Keep async functions simple and testable.
Can I use async with database libraries like SQLAlchemy?
Standard SQLAlchemy is synchronous. Use SQLAlchemy 2.0+ with async engine or async-specific libraries like asyncpg (PostgreSQL), motor (MongoDB), or databases. They provide await-friendly query APIs.