Where your handler runs, and the one mistake that turns a fast framework into a slow one.
Overview
Two placements, one interface
FastAPI lets you write an endpoint as either async def or plain def. Both work, and both look the same to a client. The difference is where your code runs. An async handler runs directly on the event loop. A plain handler is sent to a pool of worker threads so it does not block that loop.
This choice decides how well your app handles many users at once. A fast framework can still be slowed badly by one mistake, which is doing slow, blocking work inside an async function. While that work runs, the event loop cannot serve anyone else, and the entire app appears frozen.
This article starts with the two placements and the single interface that hides them. It then explains why an event loop is fast in the first place, because the rule makes more sense once you know the reason. You will see the mistake in detail: calling a blocking library from inside an async handler.
A short, clear rule follows. Use async def when everything inside is awaited, and plain def when you call code that blocks. Dependencies follow the same logic according to how they are defined, which the article covers as well.
Many developers pick one style at random and never think about it. A few minutes here can save you from a performance problem that is hard to trace later.
You do not need to be a concurrency expert. A basic picture of one worker serving many requests by switching between them is enough, and the article builds that picture from scratch. Each section is short and ends with a habit you can apply straight away. You finish with a clear rule for choosing between the two forms, and with the warning signs that tell you a handler is blocking the loop when it should not be.
A caller can't tell the gap. The response is the same, the docs is the same, check is the same.
What differs is where the function runs. An async def endpoint is awaited right on the event loop, in the same thread that is handling every other concurrent request. A def endpoint is handed to a threadpool. The loop is free while it works.
That single fact shows all else in this module.
Worth knowing
A def endpoint runs in a threadpool; an async def one runs on the event loop. Callers cannot tell the difference.
The mistake is async def with a blocking call inside: nothing errors, and every other request in the process waits.
If a handler awaits something, write async def. If it makes a blocking call, write def and let the threadpool take it.
When it does neither, def is the safer default — a blocking call added later cannot stall the loop.
Dependencies are placed by their own definition, so an async handler can depend on a sync dependency and the reverse.
The threadpool is finite. A great many slow sync handlers exhaust it, which is a different limit from stalling the loop and looks similar from outside.
The editors here run without a live event loop, so an await that genuinely suspends — asyncio.sleep, a real network call — cannot be demonstrated. Awaiting coroutines that complete works, and is the same shape.
async def or def
Where your handler runs, and the one mistake that turns a fast framework into a slow one.
Both are ordinary endpoints
The framework accepts either, and a caller cannot tell which you wrote. The difference is where the function runs.
example_01.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
@app.get("/sync")
def sync_endpoint():
return {"style": "def"}
@app.get("/async")
async def async_endpoint():
return {"style": "async def"}
c = TestClient(app)
print("sync :", c.get("/sync").status_code, c.get("/sync").json())
print("async:", c.get("/async").status_code, c.get("/async").json())
print()
print("Identical from outside. The choice is about what the body does.")
Output
A sync handler is moved off the loop
Starlette sends a def endpoint to a threadpool, so a blocking call in it cannot stall the loop.
example_02.pyFastAPI
import threading
from fastapi import FastAPI
app = FastAPI()
where = {}
@app.get("/sync")
def sync_endpoint():
where["sync"] = threading.current_thread().name
return {"thread": where["sync"]}
@app.get("/async")
async def async_endpoint():
where["async"] = threading.current_thread().name
return {"thread": where["async"]}
c = TestClient(app)
print("sync :", c.get("/sync").json())
print("async:", c.get("/async").json())
print()
print("In a real server these differ: the sync one runs on a worker thread,")
print("the async one on the thread owning the event loop.")
Output
The mistake
async def plus a blocking call. Nothing errors, and every other request in the process waits for it.
example_03.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
def slow_blocking_call():
total = 0
for i in range(200_000): # stands in for a synchronous DB driver
total += i
return total
@app.get("/wrong")
async def wrong(): # async, but blocks the loop
return {"total": slow_blocking_call()}
@app.get("/right")
def right(): # sync, so it runs off the loop
return {"total": slow_blocking_call()}
c = TestClient(app)
print("both return the same thing:")
print(" /wrong:", c.get("/wrong").json())
print(" /right:", c.get("/right").json())
print()
print("Only one of them let the server keep serving while it worked.")
Output
@app.get("/users")
async def users():
return db.query(User).all() # a blocking driver
This looks modern and is the single most common speed bug in FastAPI uses.
The function is async, so it runs on the loop. The query is synchronous, so it doesn't yield. For the entire duration of that query the loop is stuck. No other request progresses, no other handler runs, no keepalive is answered. One slow query has stopped the whole process.
Nothing errors. The endpoint returns right. It's only under concurrency that the use turns out to handle one request at a time. The symptom. All is slow when the system is busy — points nowhere handy.
Now Starlette runs it in a threadpool and the loop keeps going.
Awaiting inside an async handler
An async def endpoint earns its keep when it awaits — the loop runs something else while it waits.
example_04.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
order = []
async def fetch(name):
order.append("start " + name)
value = await lookup(name) # awaiting another coroutine
order.append("done " + name)
return value
async def lookup(name):
return name.upper()
@app.get("/chain")
async def chain():
a = await fetch("a")
b = await fetch("b")
return {"results": [a, b], "order": list(order)}
print(TestClient(app).get("/chain").json())
print()
print("An async handler awaiting async functions, all the way down.")
print()
print("Real interleaving - asyncio.gather over calls that actually wait -")
print("needs a running event loop, which this page does not have. The")
print("shape is the same; the concurrency is what a server adds.")
Output
Dependencies follow the same rule
A def dependency goes to the threadpool and an async def one runs on the loop, independently of the handler.
example_05.pyFastAPI
import threading
from fastapi import Depends, FastAPI
app = FastAPI()
def sync_dep():
return {"dep": "sync", "thread": threading.current_thread().name}
async def async_dep():
return {"dep": "async", "thread": threading.current_thread().name}
@app.get("/mixed")
async def mixed(a: dict = Depends(sync_dep), b: dict = Depends(async_dep)):
return {"a": a["dep"], "b": b["dep"]}
print(TestClient(app).get("/mixed").json())
print()
print("An async handler may depend on a sync dependency and the reverse.")
print("Each is placed by its own definition, not by the handler's.")
Output
Choosing, mechanically
One question decides it: does the body await anything?
example_06.pyFastAPI
from fastapi import FastAPI
app = FastAPI()
# Awaits something -> async def.
async def load():
return "loaded"
@app.get("/awaits")
async def awaits():
await load()
return {"rule": "awaits something -> async def"}
# Blocks -> def, and let the threadpool have it.
@app.get("/blocks")
def blocks():
sum(range(50_000))
return {"rule": "blocking call -> def"}
# Neither -> either works; def is the safer default.
@app.get("/neither")
def neither():
return {"rule": "pure computation, no I/O -> def is fine"}
c = TestClient(app)
for p in ["/awaits", "/blocks", "/neither"]:
print(c.get(p).json())
Output
Why an event loop is fast
An ASGI server handles many requests in one thread by never waiting. When a handler awaits a DB query, the loop parks it and runs a thing else. Thousands of requests can be in flight with almost none of them consuming anything but memory.
That works because awaiting yields control. It stops working the moment a thing doesn't.
The rule
Does the body await anything?
Yes — async def. An async DB driver, an async HTTP client, asyncio.sleep, another async function.
No, and it blocks — def. A synchronous driver, requests, file I/O, a CPU-bound math.
Neither — either works, and def is the safer default. A blocking call added later can't stall anything.
The rule to distrust is "async is faster". Async is faster *when it awaits*. An async handler that blocks is slower than the sync version of the same code. It takes the whole process with it.
Dependencies follow their own definition
A dependency is placed by how *it* is written, not by the handler.
So an async def handler can depend on a def dependency. The dependency goes to the threadpool, the handler stays on the loop. And the reverse works too. Mixing is normal and correct.
The same rule applies to each: if the dependency opens a link with a blocking driver, it should be def.
The threadpool is finite
Sync handlers are safe for the loop and not free. Starlette's threadpool has a limited number of workers. A few dozen by default.
If each request is a slow sync handler, those threads fill up and further requests queue waiting for one. The loop is healthy and the use is still stuck. This Looks similar from outside and has a other cause.
That is the real arg for async drivers under high concurrency. Not that the syntax is better. That awaiting costs a coroutine and blocking costs a thread. There are far more coroutines on hand than threads.
For most uses, the threadpool is fully adequate and the simplicity of sync code is worth more than the ceiling.
Do not mix them badly
Two exact things to avoid.
Calling a sync function that blocks from inside an async handler. That is the mistake above wearing a other hat. If you must, await run_in_threadpool(fn) moves it off the loop by name.
**Calling asyncio.run() inside a handler.** There's already a loop running. Starting another raises. To call an async function from a sync handler, the honest answer is often to make the handler async.
Mistakes people make with async def or def
**async def with a blocking call.** The one that matters. Nothing errors and the whole process serves one request at a time under load, with a symptom - all is slow when busy - that points nowhere handy.
given async is faster. Async is faster when it awaits. An async handler that blocks is worse than the sync version. It takes each concurrent request with it.
**asyncio.run() inside a handler.** There's already a loop. Starting another raises. To call an async function from a sync handler, make the handler async.
Mixing drivers without noticing. An async endpoint using a synchronous ORM is the same bug wearing other clothes. If the driver blocks, the endpoint should be def.
Skipping the threadpool ceiling. Sync handlers are safe for the loop and finite in number. Enough slow ones exhaust the pool. This Looks the same from outside and has a other cause.
Choosing per handler with no rule. Then no one can tell whether a given endpoint is safe to add a blocking call to. Eventually somebody adds one to the wrong sort.
Diagnosing it
Two symptoms set apart the failures.
The loop is stalled: latency rises across every endpoint at once, including trivial ones, and a health check that does nothing takes seconds. Something async is blocking.
The threadpool is full: the fast endpoints stay fast while requests to slow sync ones queue. The loop is fine; the workers are all busy.
The fix differs. The first needs the blocking call moved off the loop - change async def to def, or wrap it in run_in_threadpool. The second needs fewer slow synchronous steps, more workers, or async drivers.
Being consistent
An use that is mostly sync and mostly fast is a just right good use. So is one that is async throughout with async drivers. Both scale further than most services ever need.
What causes trouble is a code where the choice was made per handler by whoever wrote it. Then no one can look at an endpoint and tell whether adding a blocking call to it's safe. Eventually somebody adds one to the wrong sort.
Pick a default, write it in the project's README. Make the exception deliberate. "Sync unless it awaits" is a fine rule. So is "async all over. Each driver must be async". The rule matters more than which one.
Where the ceilings are
Two limits, and telling them apart is most of diagnosing a slow FastAPI service.
The loop is stalled by any blocking call in an async def handler or dependency. One slow query stops each concurrent request in that process. The symptom is that all gets slow at once, including endpoints that do nothing.
The threadpool is exhausted by enough concurrent def handlers. The loop stays healthy and fast endpoints stay fast, while requests to slow ones queue for a worker.
The first is a bug and the fix is free. Move the blocking call off the loop. The second is a room limit. The fixes are real - fewer slow synchronous steps, more workers, or async drivers so waiting costs a coroutine rather than a thread.
Knowing which you've takes one finding: does a trivial endpoint also get slow? If yes, the loop. If no, the pool.
What async actually buys
Worth stating plainly, because "async is faster" is both common and wrong.
Async doesn't make any single request faster. A query takes as long either way.
What it changes is how many requests one process can have in flight while waiting. With threads, waiting costs a thread and there are hundreds on hand. With coroutines, waiting costs a few kilobytes and there are hundreds of thousands on hand.
For an API that spends most of its time waiting on other systems - which is most APIs - that is the gap between a machine handling a few hundred concurrent requests and one handling many thousands. It's a concurrency win, not a delay one. Only if the waiting is done by awaiting.
async def or def: summary
def runs in a threadpool. async def runs on the event loop. Callers can't tell.
Write async def when the body awaits, def when it blocks. def by default when it does neither - because a blocking call added later can't then stall anything.
Dependencies are placed by their own meaning. Mixing is normal. And note there are two ceilings. The loop. This One blocking call can stall. The threadpool. This Enough slow sync handlers can exhaust.
Where to go after async def or def
Work that should happen after the response has been sent - what background tasks are for, what they aren't. The point at which they need to become a real queue.
The rule, once more
Awaits a thing, async def. Blocks, def. Neither, def.
That covers each case, and the reason the third clause defaults to def is that it's the only one that stays correct when somebody later adds a blocking call to a handler that used to do nothing much.
A closing thought
This is the one place where FastAPI will let you write a thing that looks right, passes each test, and fails only under load.
Nothing warns about async def around a blocking call. The endpoint is correct, the tests pass because they run one request at a time. The problem appears in production as generalised slowness with no obvious cause.
That skew - easy to write, hard to notice, costly to diagnose - is why it's worth knowing the rule properly rather than choosing by habit.
One more diagnostic
If a service is slow and you aren't sure which ceiling you've hit, the cheapest test is an endpoint that does nothing:
@app.get("/ping")
def ping():
return {"ok": True}
Under load, hit it. If it's fast while other endpoints crawl, the loop is healthy and the threadpool is saturated. If it's also slow, a thing is blocking the loop.
That single finding splits a room problem from a bug. The two have fully other fixes.
async def or def in one line
async def when the body awaits, def when it blocks, def by default when it does neither - because that is the only choice that stays correct when somebody adds a blocking call later.
And if a service is slow and you can't tell which ceiling you hit, hit an endpoint that does nothing. If it's fast, the pool is full. If it's slow, the loop is blocked.
Questions people ask about async def or def
When should I use async def? When the body awaits async libraries, such as an async database driver or httpx.AsyncClient.
What happens to a plain def endpoint? FastAPI runs it in a thread pool so it does not block the event loop.
What goes wrong if I call a blocking function inside async def? The whole event loop stalls, so every other request waits until that call returns.
Is async faster than sync? Only under many concurrent I/O waits. For CPU-bound work neither helps, and a sync endpoint in a thread is just as fast for a single request.
How do I call blocking code from an async endpoint? Wrap it with await run_in_threadpool(func) or asyncio.to_thread.
Do dependencies follow the same rule? Yes. Each dependency is sync or async on its own, and FastAPI treats it that way independent of the endpoint.
Where the loop itself is explained
This page decides one thing: async def or def. What FastAPI does with each. The thing being decided *about*. A single-threaded loop that only makes progress when the coroutine on it suspends. Is worth seeing on its own, away from routing and dependencies.
Coroutines, tasks. What await really does. Why awaiting two calls in a row costs the sum of their times and wrapping them as tasks costs the max. That is the same mistake as a handler which awaits its three upstream calls one after another. It's invisible in a load test with one client.
The blocking call that freezes the loop. The method behind "one slow request made each request slow", and the case the threadpool by name doesn't rescue you from.
The layer below is worth a look too. The threadpool a def endpoint runs in is an plain thread pool with the plain constraints. The GIL. What it really locks is why a def endpoint doing CPU work stops scaling with workers while one doing I/O keeps going. choosing threads, processes, or async is this page's choice one level up from the route.
Check yourself
0 of 4
Answer without scrolling back up.
Where does a `def` endpoint run?
Starlette moves it off the loop, which is why a blocking call inside a sync handler cannot stall other requests.
What happens with `async def` plus a blocking database call?
The most common performance bug in FastAPI applications. It returns correctly and only fails under concurrency, where the symptom points nowhere useful.
Your handler makes no I/O calls at all. Which should you write?
`async` is faster when it awaits. With nothing to await it gains nothing and leaves a trap for whoever adds a blocking call next.
An async handler depends on a `def` dependency. What happens?
Each is placed by its own definition, so mixing is normal - and a dependency using a blocking driver should be `def` regardless of the handler.
Cheat sheet
async def or def
Compare async def and plain def endpoints in FastAPI, see where each one runs, and spot the mistake that turns a fast framework into a slow one.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.