Python

Async Python Explained: asyncio, await and When to Use It

Parallel lanes of light flowing through a glowing event loop ring

Async Python has a reputation for being confusing — async def, await, event loops, coroutines, “why is it not faster?” But underneath the jargon is one simple idea: stop waiting around. This guide explains what async really does, when it genuinely helps (and when it doesn’t), and the traps that silently ruin performance.

The core idea: waiting is wasted time

Imagine a program that fetches 100 web pages. The slow part isn’t your CPU — it’s the waiting. Each request sends a little data, then sits idle for tens or hundreds of milliseconds waiting for the server to respond. In ordinary synchronous code, your program does this one page at a time: request, wait, request, wait. For 100 pages, all that idle time adds up to a very slow program that is doing almost no actual work.

Async Python’s insight is that while one request is waiting, the program could be starting the others. Instead of standing in line, you fire off many requests and let them wait concurrently. When responses come back, you process them. The total time collapses from “the sum of all waits” to roughly “the longest single wait.”

That is what async is for: I/O-bound work — networking, databases, file access — especially a lot of it at once.

Coroutines, await and the event loop

Three concepts carry the whole model.

A coroutine is a function defined with async def. Calling it doesn’t run it immediately — it returns a coroutine object, a plan to do work that hasn’t started yet.

import asyncio

async def fetch(name):
    print(f"start {name}")
    await asyncio.sleep(1)      # pretend this is a network call
    print(f"done {name}")
    return name

The keyword await is the heart of it. await marks a point where the coroutine says: “this will take a while — pause me here and go do something useful until the result is ready.” It is a cooperative yield point, not a blocking stop.

The event loop is the conductor. It keeps a set of tasks and runs them until each hits an await, at which point it parks that task and switches to another that’s ready. One thread, many tasks, no waiting around.

Concurrency in action

Run those coroutines the naive way and you gain nothing:

async def main_slow():
    await fetch("A")   # waits 1s
    await fetch("B")   # then waits another 1s  -> ~2s total

await fetch("A") fully finishes before fetch("B") starts. To get real concurrency, schedule them together with asyncio.gather:

async def main_fast():
    await asyncio.gather(fetch("A"), fetch("B"))   # ~1s total

asyncio.run(main_fast())

Now both start, both wait at the same time, and the whole thing finishes in about one second instead of two. Scale that to hundreds of tasks and the difference is dramatic. This is the single most important pattern in async Python: create many tasks, then await them together.

Async vs. threads vs. processes

Python gives you three tools for doing more than one thing, and picking the right one matters more than any micro-optimization:

  • asyncio (this article) — one thread, cooperative switching at await points. Fantastic for large numbers of I/O-bound tasks (thousands of network connections) with low overhead. No data races from parallelism, because only one piece of your code runs at a time.
  • Threads — multiple threads, switched preemptively by the OS. Good for I/O-bound work when you’re using blocking libraries that aren’t async-aware. Historically limited for CPU work by the Global Interpreter Lock.
  • Multiprocessing — multiple processes, true parallel execution across CPU cores. This is the right choice for CPU-bound work: image processing, number crunching, heavy parsing.

The rule of thumb: I/O-bound and lots of it → async; CPU-bound → multiprocessing. If your bottleneck is waiting, async wins; if your bottleneck is computing, async does nothing for you.

The mistake that quietly kills async performance

Here’s the trap that catches almost everyone. The event loop runs on one thread. If you call something blocking inside a coroutine, you freeze the entire loop — every other task stops dead until it returns.

async def bad():
    time.sleep(5)        # BLOCKS the whole event loop for 5 seconds
    requests.get(url)    # a synchronous library also blocks everything

Two fixes:

  1. Use async-native libraries. Reach for asyncio.sleep() instead of time.sleep(), and an async HTTP client (like httpx or aiohttp) instead of a synchronous one. Async only works when the whole path down to the I/O is non-blocking.
  2. Offload unavoidable blocking calls. If you must call a synchronous library, push it to a thread pool with asyncio.to_thread(...) so it doesn’t stall the loop.

If your async code “isn’t any faster,” a blocking call hiding in the loop is almost always the reason.

When not to use async

Async is not free. It adds cognitive overhead — colored functions (async all the way down), different libraries, trickier debugging. Skip it when:

  • Your program is a simple script doing one thing at a time.
  • Your work is CPU-bound (use multiprocessing).
  • You only make a handful of I/O calls where the added complexity isn’t worth it.

Reach for async when you have many concurrent I/O operations — a web server handling thousands of connections, a scraper fetching thousands of pages, a service fanning out to many APIs. That’s where it shines, and it’s why modern Python web frameworks and much of today’s AI tooling are built on it.

The takeaway

Async Python is one idea wearing intimidating clothes: don’t sit idle while waiting on I/O — do other useful work instead. Learn the trio (coroutine, await, event loop), remember to gather tasks for real concurrency, never block the loop, and match the tool to the job (async for I/O, multiprocessing for CPU). Get those right and async stops being scary and starts being one of the most powerful tools in your Python toolkit.

Frequently Asked Questions

When should I use async in Python?

Use async for I/O-bound work — anything that spends most of its time waiting on the network, a database or the disk, especially many operations at once. For CPU-bound work (heavy computation), async does not help; use multiprocessing instead.

Is asyncio the same as multithreading?

No. asyncio runs on a single thread and switches between tasks cooperatively at await points, so there are no data races from parallel execution. Threads run preemptively and can execute in parallel (subject to the GIL). Async is lighter for large numbers of I/O-bound tasks.

What does await actually do?

await tells the event loop: 'this operation will take a while — pause my coroutine here and go run other tasks until the result is ready.' It's a cooperative yield point, not a blocking wait, which is why one thread can juggle thousands of connections.

Why is my async code not any faster?

Usually because something blocking is running inside the event loop — a synchronous library call, time.sleep(), or heavy CPU work. That freezes every task. Use async-native libraries and await asyncio.sleep(), or offload blocking calls to a thread/process pool.



Related Articles