Python Async/Await Explained (Without the Jargon)
A plain-English explanation of Python's async/await, when it actually helps, and the mistakes that make it worse than sync code
Async/await is one of the most misunderstood parts of Python — developers reach for it assuming it makes code faster, then get confused when it doesn’t (or makes things worse). Here’s what it actually does.
What async actually solves
Async doesn’t make your code run faster at doing CPU work — a for loop crunching numbers takes exactly as long whether it’s in an async def function or not. What async solves is waiting: when your code is waiting on something external (a network request, a database query, a file read), async lets your program do other useful work during that wait instead of sitting idle.
This means async helps enormously for I/O-bound workloads — a web server handling hundreds of concurrent requests, each mostly waiting on a database — and does essentially nothing for CPU-bound workloads like image processing or numerical computation.
The basic syntax
import asyncio
async def fetch_data():
await asyncio.sleep(1) # simulates waiting on I/O
return "data"
async def main():
result = await fetch_data()
print(result)
asyncio.run(main())
async def marks a function as a coroutine — calling it doesn’t run it immediately, it creates a coroutine object that needs to be awaited (or scheduled) to actually execute. await pauses the current coroutine at that point, letting the event loop run other pending work until the awaited thing completes.
The mistake that makes async worse than sync
The single most common mistake: awaiting things one at a time when they could run concurrently.
# Slow — runs sequentially, one after another
result1 = await fetch_a()
result2 = await fetch_b()
result3 = await fetch_c()
# Fast — runs concurrently
result1, result2, result3 = await asyncio.gather(fetch_a(), fetch_b(), fetch_c())
The first version gets you nothing over synchronous code — you’re still waiting for each call in sequence. asyncio.gather (or asyncio.TaskGroup in modern Python) is what actually delivers the concurrency benefit, by letting all three requests be in flight at once.
You can’t mix sync and async carelessly
Calling a blocking synchronous function (like a non-async database driver, or time.sleep instead of asyncio.sleep) from inside an async function blocks the entire event loop — every other coroutine waiting on that loop stalls too. This is the most common source of “async made my app slower” bug reports: one blocking call inside an async function defeats the entire point.
If you need to call blocking code from an async context, use asyncio.to_thread() to run it in a separate thread without blocking the event loop.
Where you’ll actually use this
FastAPI, aiohttp, and most modern Python web frameworks are async-first — if you’re building APIs today, you’ll encounter async def route handlers immediately, whether or not you fully understand what they’re doing yet. HTTPX supports both sync and async clients, which is a good way to see the same code in both styles side by side.
Do you need to learn this right away?
Not for early-stage learning — get comfortable with core Python and synchronous code first. Async becomes necessary once you’re building anything that talks to external services under real concurrent load: a production API, a web scraper hitting many URLs, or a bot handling multiple simultaneous conversations. Trying to learn async and core Python fundamentals simultaneously tends to confuse both.