Async vs. Threading: The Battle for Supremacy
FastAPI made everyone write async def, but do you know why? Let's settle the fight between Python's two concurrency models.
- Published On
12 min read
Lately it feels like every FastAPI service I open has async def sprinkled on every single endpoint, whether it's fetching a row from Postgres or just returning {"status": "ok"}. I've started asking people why, and the honest answer is almost always the same: "because it's faster, right?". That's not really an answer, it's a guess, and it's the same half-understanding that made people call Python "single-threaded" in my last post.
So let's fix that. This post picks up exactly where We need to talk about GIL! left off, and puts Python's two concurrency models, threading and asyncio, in the ring together. By the end you'll know exactly when threads win, when async wins, and, just as important, when neither of them can help you at all.
Previously, on the GIL...
Quick recap, three facts, no rehash:
- The Global Interpreter Lock means only one thread executes Python bytecode at a time.
- The GIL is released whenever a thread blocks on I/O, that loophole is the whole reason both threading and asyncio exist.
- CPU-bound work gets no help from either of them. That's
multiprocessingterritory, and I'm not repeating that post here.
If "GIL", "mutex" or "I/O-bound" don't ring a bell yet, go read We need to talk about GIL! first. This post assumes you already know what the GIL is and why it exists, here we only care about what you can build around it.
The real enemy: waiting
Before comparing tools, let's name the actual problem they're both trying to solve. Open any typical web request in a profiler and you'll notice something humbling: your CPU barely does anything. The vast majority of the wall-clock time is spent waiting, for the database to answer a query, for a downstream HTTP API to respond, for a disk read to finish. The process isn't computing, it's just parked.
This is the split you need in your head from now on: CPU-bound work (parsing, hashing, image processing, machine learning inference) actually keeps a core busy the whole time. I/O-bound work spends almost all of its time blocked, waiting on something that isn't the CPU at all. Concurrency in a Python web service is, in the overwhelming majority of cases, about the second kind. It's not about doing more computation, it's about not standing still while you wait.
Here's the baseline nobody wants to admit they still write: fetch three URLs, one after another, and just... wait.
import time
import requests
def fetch(url):
start = time.perf_counter()
response = requests.get(url)
elapsed = time.perf_counter() - start
print(f"{url} -> {response.status_code} in {elapsed:.2f}s")
return response
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
start = time.perf_counter()
for url in urls:
fetch(url)
print(f"Total: {time.perf_counter() - start:.2f}s")Three requests, each taking about a second, run one after another. Total: roughly 3 seconds. Your CPU was free for 2.9 of those 3 seconds and did nothing with the time. This is exactly the gap threading and asyncio exist to close.
Contender #1: Threading
Threads are managed by the operating system, and the OS is preemptive: it can pause a thread and hand the CPU to another one at basically any point, whether the thread likes it or not. In CPython, when a thread blocks on I/O, it releases the GIL, and the OS scheduler is free to run a different thread in the meantime. The killer feature here is that this works with code you already have. requests, psycopg2, any classic blocking library, just works, unmodified, inside a thread.
from concurrent.futures import ThreadPoolExecutor
import time
import requests
def fetch(url):
start = time.perf_counter()
response = requests.get(url)
elapsed = time.perf_counter() - start
print(f"{url} -> {response.status_code} in {elapsed:.2f}s")
return response
urls = ["https://httpbin.org/delay/1"] * 3
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=3) as executor:
list(executor.map(fetch, urls))
print(f"Total: {time.perf_counter() - start:.2f}s")Same fetch function, not a single line changed inside it. Total time now: roughly 1 second, the duration of the slowest request, because all three were in flight waiting at the same time.
That convenience has a price. Every thread carries its own stack (megabytes, not bytes), and the OS has to context-switch between them, so threads don't scale to tens of thousands of concurrent tasks the way we'd like. And because the OS can interrupt a thread between literally any two bytecodes, any state shared across threads needs explicit protection.
Preemption means you never control exactly when a switch happens. If two threads touch the same shared variable without a Lock, you get the exact race condition from the GIL post's counter example: two threads incrementing the same number 1,000,000 times each, and the final result isn't 2,000,000. Threads buy you free concurrency for I/O; they don't buy you free correctness for shared state.
Contender #2: Asyncio
Asyncio flips the whole model around. Instead of many OS threads, you get one thread and one event loop, and instead of the OS preempting tasks whenever it feels like it, tasks volunteer to pause, at every await. This is called cooperative scheduling.
The mental model that matters here: preemptive means the OS taps a task on the shoulder and says "your turn is over", at any point, without asking. Cooperative means a task runs uninterrupted until it explicitly says await, which really means "I'm about to wait on something anyway, someone else can go ahead." Nothing switches unless a task chooses to yield.
The event loop keeps a queue of tasks. When a task hits an await on something slow (a socket read, a timer), the loop parks it and picks up the next ready task. When the slow thing finishes, the parked task goes back in the queue. Because tasks are just lightweight objects, not OS threads, you can have tens of thousands of them alive at once for a fraction of the memory cost.
import asyncio
import httpx
async def fetch(client, url):
start = asyncio.get_event_loop().time()
response = await client.get(url)
elapsed = asyncio.get_event_loop().time() - start
print(f"{url} -> {response.status_code} in {elapsed:.2f}s")
return response
async def main():
urls = ["https://httpbin.org/delay/1"] * 3
async with httpx.AsyncClient() as client:
await asyncio.gather(*(fetch(client, url) for url in urls))
asyncio.run(main())Same result as the threaded version, roughly 1 second total, but this approach holds up just as well at 10,000 URLs, where spinning up 10,000 threads simply isn't realistic.
The catch is that this only works if everything in the call chain is async-native. Async is viral: requests becomes httpx, psycopg2 becomes asyncpg, and so on, all the way down. And there's a sharper trap waiting for the ones who don't switch libraries:
import asyncio
import time
async def broken_task(name):
print(f"{name} starting")
time.sleep(1) # should be: await asyncio.sleep(1)
print(f"{name} done")
async def main():
await asyncio.gather(broken_task("A"), broken_task("B"))
asyncio.run(main())Swap await asyncio.sleep(1) for plain time.sleep(1) and the two tasks that should have finished in 1 second together now take 2, run one after another. time.sleep doesn't yield, so nothing else on the loop gets a turn until it returns.
One time.sleep, one plain requests.get, one heavy pandas transformation inside a coroutine, and your entire event loop stops. Not just that task, every task sharing that loop. This is the single most common async bug in production, and we'll see it show up again, wearing a FastAPI costume, in the next section.
What actually yields, at the bottom of the chain
There's a question hiding in that last example: you can await all day inside your own async def functions, chaining calls ten levels deep, and none of that, by itself, hands control back to the event loop. await on a plain coroutine just resumes it and runs it to its own next await, in the same step, no different from a regular function call. The real handoff to the loop happens exactly once per suspension, and only when the chain bottoms out in something that isn't a coroutine at all: an asyncio.Future.
A Future is __await__-able, and its implementation looks roughly like this:
def __await__(self):
if not self.done():
self._asyncio_future_blocking = True
yield self # the one real suspension point
return self.result()That yield is the only actual yield in the entire chain. Every await you write on a coroutine just walks down the call stack looking for it. When the Task running your coroutine hits that yield, it registers a callback with the loop, via loop.add_reader for a socket, loop.call_later for a timer, and returns control, letting the loop run something else until the Future resolves and the callback reschedules your Task.
asyncio.sleep() and every async I/O call (httpx, asyncpg, raw sockets through loop.sock_recv) end in exactly this pattern: create a Future, hand it to the loop, yield it, get woken up later. That's why time.sleep() inside a coroutine never yields anything: it isn't a Future, it's not hooked into the loop, it's just the CPU spinning until the call returns, with no yield anywhere for the Task to catch.
So writing a truly cooperative async function takes more than making the outermost call async def. Every layer, all the way down, has to eventually await something that resolves to one of these loop-registered Futures, not a computation that merely happens to live inside a coroutine.
Quick gut check: trace a coroutine's call chain to the very bottom. If it doesn't end in an await on I/O, a timer, or an explicit await asyncio.sleep(0), it never actually yields, no matter how many async defs wrap it along the way.
Head to head
Neither column below says "runs Python in parallel", because neither does, the GIL still guarantees that. This is purely about who manages the waiting better.
| Aspect | Threading | Asyncio |
|---|---|---|
| Scheduling | Preemptive (OS decides) | Cooperative (task yields at await) |
| Concurrency unit cost | OS thread, megabytes of stack each | Coroutine/task, kilobytes each |
| Realistic scale | Dozens to a few hundred | Tens of thousands |
| Works with blocking libraries | Yes, unchanged | No, needs async-native replacements |
| Race conditions | Likely without Locks | Rare, switches only happen at await |
| Effect of one slow/blocking call | Only that thread stalls | The entire loop stalls |
| CPU-bound parallelism | No (GIL) | No (GIL, and single-threaded anyway) |
FastAPI enters the room
This is where the original question finally gets an answer. FastAPI, through Starlette and AnyIO underneath it, actually runs both models at once and picks per endpoint. An async def endpoint runs directly on the event loop. A plain def endpoint is automatically dispatched to an external threadpool, precisely so it can't block the loop. A synchronous endpoint isn't a mistake, it's threading, managed for you.
from fastapi import FastAPI
import httpx
import requests
app = FastAPI()
@app.get("/sync") # dispatched to the threadpool
def call_service_sync():
r = requests.get("https://api.example.com/data")
return r.json()
@app.get("/async") # runs directly on the event loop
async def call_service_async():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data")
return r.json()Both endpoints are correct Python. Under low load you won't notice a difference. Under high concurrency you will: a parked coroutine costs almost nothing, so a worker running /async can hold thousands of in-flight requests. The /sync endpoint is bounded by the threadpool.
AnyIO's default threadpool caps out around 40 threads per worker process. That's your hard ceiling for concurrent def requests on a single worker; the 41st simply queues behind the others. The event loop's ceiling for async def is, in practice, however much memory you have. Check your AnyIO/Starlette version if this number matters to your capacity planning, it's a tunable, not a law of physics.
The reverse trap is worse than a slow sync endpoint. Write async def and then block inside it, and you don't just slow down that one request:
from fastapi import FastAPI
import time
app = FastAPI()
@app.get("/broken")
async def call_service_broken():
time.sleep(5) # blocks the entire event loop for 5 seconds
return {"status": "ok"}While this coroutine sleeps, every other request assigned to that worker's event loop, including your health check, hangs right along with it.
If you write async def, everything inside it must be awaitable, or fast enough that it doesn't matter. If you can't guarantee that, plain def is not a downgrade, it's the correct choice, and FastAPI will thread it for you automatically. This exact rule is the first thing zhanymkanov's fastapi-best-practices guide covers, for good reason.
Whose event loop is it, anyway?
Here's a question I got asked more than once, and it's a fair one: FastAPI is a framework, so where does the actual event loop come from? FastAPI itself doesn't run one. It's an ASGI application, a callable that describes how to handle a request, nothing more. The event loop is supplied by whatever ASGI server boots it, almost always Uvicorn. When Uvicorn starts, it creates one asyncio event loop per worker process, and by default it swaps the standard library's loop for uvloop, a drop-in implementation built on libuv (the same library behind Node.js) that's noticeably faster at the same job.
This has a consequence worth naming explicitly: --workers and async def solve two completely different problems.
uvicorn app:app --workers 4Four workers means four separate OS processes, each with its own interpreter, its own GIL, and its own event loop. That's real, process-level parallelism, the same kind multiprocessing gives you, and it's what actually lets a FastAPI deployment use more than one CPU core. Inside each of those processes, the single event loop is what gives you high concurrency: one core juggling thousands of parked requests, exactly as described earlier in this post, just now with a name and a process boundary attached to it. Every incoming request on a worker becomes a task scheduled onto that worker's loop; the loop doesn't know or care about the other three workers running right next to it.
Workers buy you parallelism across cores. Async buys you concurrency within a core. Tuning only one of the two, adding async def everywhere while running a single worker, or adding workers while every endpoint blocks the loop, is a common way FastAPI deployments end up slower than expected under load.
So, who wins? How to actually decide
Here's the whole decision as a flow: three questions, four possible landings.
- Is the task CPU-bound? Parsing huge files, running ML inference, crunching numbers in pure Python. Then neither threading nor asyncio helps: async gives you zero parallelism because it's one thread, and threading gives you zero parallelism because of the GIL. This is
multiprocessing/ProcessPoolExecutorterritory, or, inside a FastAPI app, offloading to a background worker queue like Celery or arq. Full detail on that path lives in the GIL post. - I/O-bound, with async-native libraries available end to end? Asyncio wins, especially at high fan-out: many simultaneous slow calls, websockets, thousands of open connections.
- I/O-bound, but stuck with a blocking library (a legacy SDK, an ORM with no async driver)? Threading wins here: a plain
defendpoint in FastAPI, aThreadPoolExecutor, or, if you're already inside async code and just need to call one blocking function,await asyncio.to_thread(blocking_fn)is the official escape hatch. - Modest concurrency and simple code? Plain synchronous code is genuinely fine. Don't pay async's cognitive tax for a service handling ten requests a second.
The one-line takeaway of this entire post: async does not make your code faster. It makes waiting cheaper. If your code isn't waiting on anything, async has nothing to offer you, and the GIL makes sure threading doesn't either.
Conclusion
There's no real "supremacy" here, the battle framing was always a bit of a trick. Threading and asyncio solve the exact same problem, idle time spent waiting, with opposite trade-offs: threads buy you compatibility with the code you already have at the cost of scale and the need for locks; asyncio buys you scale at the cost of rewriting your dependencies and a sharper failure mode when something blocks by accident. FastAPI's actual genius isn't "async is faster", it's letting you choose the right tool per endpoint instead of forcing one model on your whole application.
Pick based on what your task is actually doing, not on what's fashionable to type. And keep an eye on free-threaded Python (PEP 703, now shipping as an experimental build), it's already starting to shift these trade-offs again, which might just be the topic for post number three.
Further Reading
- FastAPI Best Practices, zhanymkanov
- asyncio, Asynchronous I/O (Python docs)
- threading, Thread-based parallelism (Python docs)
- concurrent.futures, Launching parallel tasks (Python docs)
- FastAPI docs, Concurrency and async / await
- We need to talk about GIL!
As always: profile first, then pick the tool, the fastest concurrency model is the one that actually matches what your code is waiting for.