FastAPI async def vs def: Performance and When to Use Each

Key takeaways:

  • The declaration is a claim about the body: async def promises that every wait is awaited.
  • Measured at concurrency 20, async def + await and def + blocking both took 0.10s — but async def containing the same blocking call took 2.00s.
  • def is not free: the threadpool hop measured sub-millisecond and between one and three times the async def median on a handler that does nothing.
  • A def handler cannot await anything, because there is no event loop on its thread.
  • Dependencies are dispatched independently of the handler, so one route can mix both.

This guide is about the choice itself. The mechanism it rests on — how Starlette dispatches sync work and what the threadpool costs you — is covered in Async Correctness and Concurrency and, in depth, in Running Sync Code in a Threadpool.

The four combinations of handler declaration and work type A two by two matrix. Columns are the handler declaration, async def and plain def. Rows are the work the body does, awaited async input output and blocking work. Three cells are correct; async def containing blocking work is the only failing cell. Declaration versus what the body does async def def body awaits async I/O body blocks (sync I/O, CPU) correct 20 requests in 0.10s loop stays free impossible no loop on this thread so nothing can be awaited THE BUG 20 requests in 2.00s fully serialised correct 20 requests in 0.10s Starlette threads it Two of these are right, one cannot be written, and one is the reason your latency graph bends upward under load.
The declaration and the body have to agree. Only one combination of the four is wrong, and it is the one people reach for on purpose.

The Problem This Solves

Somebody reads that FastAPI is an async framework, runs a find-and-replace from def to async def, and ships it. Throughput gets worse. The endpoints that were fine are now slow, and the ones that were slow now make everything else slow too.

The mistake is treating async def as a performance switch. It is not a switch; it is an assertion about the function body — a promise that every point where this function waits is a point where it hands control back to the event loop. Keep that promise and you get concurrency for free. Break it and you get a worker that serves one request at a time.

The useful question is therefore never "should this be async?" It is "what does this body actually do while it waits?"

Why It Happens: the Declaration Is a Routing Decision

When you register a route, FastAPI inspects the endpoint once and picks a dispatch strategy for it permanently. Coroutine functions are awaited on the event loop. Plain functions are handed to a worker thread. That decision is made at import time, from the declaration alone, and it applies to the entire body — FastAPI cannot see which line inside will block.

That is the whole mechanism, and it explains the asymmetry that trips people up:

  • Declaring def when the body blocks is safe, because the blocking happens on a thread that nothing else needs.
  • Declaring async def when the body blocks is unsafe, because the blocking happens on the one thread that every other request is queued behind.

There is no runtime check for this. Nothing warns you. The code works perfectly with one user.

The Measured Difference

Every number below comes from running the four combinations through the verification harness — 20 concurrent in-process requests at each endpoint, where one unit of work is 100ms.

WORK_SECONDS = 0.10


@app.get("/q/async-await")
async def async_await() -> dict:
    """async def awaiting async I/O. The only combination that scales for free."""
    await asyncio.sleep(WORK_SECONDS)
    return {"quadrant": "async def + await"}


@app.get("/q/async-blocking")
async def async_blocking() -> dict:
    """async def calling blocking code. The bug."""
    time.sleep(WORK_SECONDS)
    return {"quadrant": "async def + blocking"}


@app.get("/q/def-blocking")
def def_blocking() -> dict:
    """Plain def doing blocking work. Correct, and Starlette threads it."""
    time.sleep(WORK_SECONDS)
    return {"quadrant": "def + blocking"}

Real output from running that app:

$ GET /probe/quadrants
200 OK
{
  "one_unit_of_work_s": 0.1,
  "perfectly_serial_would_be_s": 2.0,
  "results": [
    {
      "path": "/q/async-await",
      "concurrency": 20,
      "elapsed_s": 0.1
    },
    {
      "path": "/q/async-blocking",
      "concurrency": 20,
      "elapsed_s": 2.0
    },
    {
      "path": "/q/def-blocking",
      "concurrency": 20,
      "elapsed_s": 0.1
    }
  ]
}

Read the three numbers against perfectly_serial_would_be_s: 2.0.

async def + await finished in 0.10s — twenty requests in the time one takes, which is what a free event loop buys you. Plain def + blocking also finished in 0.10s, because Starlette put each of the twenty on its own worker thread. And async def + the identical blocking call took 2.00s: exactly the serial time, not one millisecond of overlap across twenty requests.

That is the entire argument. The two correct quadrants are indistinguishable at this resolution. The wrong one is twenty times slower, and the only difference between it and the fast one is a six-character keyword.

What def Actually Costs

def is safe, but it is not free, and the honest version of the recommendation needs a number. Measuring two endpoints that do nothing at all — no sleep, no I/O, just a dict — isolates the dispatch overhead:

$ GET /probe/thread-hop-cost
200 OK
{
  "requests_per_endpoint": 400,
  "def_is_slower_than_async_def": true,
  "hop_overhead_is_sub_millisecond": true,
  "hop_overhead_between_1x_and_3x": true
}

Four hundred sequential requests at each. The example deliberately reports the relationship rather than the raw medians: absolute microsecond figures are a property of the machine and the load at the moment of measurement, and reruns moved them substantially. What holds is that the hop is real, sub-millisecond, and between one and three times the async def baseline.

The ratio looks alarming and the absolute number is what matters. A hundred microseconds is invisible next to a 2ms database query, let alone a 50ms upstream call. It only becomes interesting on an endpoint that genuinely does nothing — a health check hit by a load balancer several times a second, a cheap in-memory lookup on a very hot path. Those are the endpoints where async def earns its keep for a reason unrelated to concurrency: it skips the hop.

Everywhere else, paying a sub-millisecond hop to guarantee you cannot stall the loop is one of the better trades available.

Why a def Handler Cannot Await

A recurring follow-up is "fine, but can I just await one thing inside my def handler?" You cannot, and the reason is worth seeing rather than asserting. The same introspection endpoint, declared both ways:

@app.get("/q/def-introspect")
def def_introspect() -> dict:
    """A def handler is off the loop entirely, which is why it cannot await anything."""
    try:
        loop = str(asyncio.get_running_loop())
        running = True
    except RuntimeError as exc:
        loop = f"RuntimeError: {exc}"
        running = False
    return {"thread": threading.current_thread().name, "event_loop_visible": running, "detail": loop}
$ GET /probe/where-each-handler-runs
200 OK
{
  "async_def_handler": {
    "thread": "MainThread",
    "event_loop_visible": true,
    "detail": "running on the event loop"
  },
  "def_handler": {
    "thread": "AnyIO worker thread",
    "event_loop_visible": false,
    "detail": "RuntimeError: no running event loop"
  }
}

The def handler runs on a thread called AnyIO worker thread, and on that thread there is no event loop at all — asyncio.get_running_loop() raises RuntimeError: no running event loop. This is not a style restriction. There is simply no loop there to schedule a coroutine on.

That has a concrete consequence for the decision: an async database session, an httpx.AsyncClient, or any other awaitable resource is unavailable inside a def handler. If your data access layer is async, the declaration is already made for you — see Async SQLAlchemy Session per Request. The choice only exists when your I/O layer is synchronous.

Dependencies Choose Separately

The last thing that changes the decision in practice: the declaration is per-callable, not per-route. FastAPI dispatches each dependency on its own terms.

def sync_dependency() -> str:
    """A plain def dependency. Starlette threads it whatever the handler is declared as."""
    time.sleep(0.01)
    return "sync"


async def async_dependency() -> str:
    return "async"


@app.get("/mixed")
async def mixed(a: str = Depends(sync_dependency), b: str = Depends(async_dependency)) -> dict:
    return {"a": a, "b": b}
$ GET /probe/dependency-dispatch
200 OK
{
  "threads": {
    "sync_dependency": "AnyIO worker thread",
    "async_dependency": "MainThread",
    "handler": "MainThread"
  },
  "sync_dependency_was_offloaded": true,
  "async_dependency_ran_on_the_loop": true,
  "threadpool_capacity_shared_with_def_handlers": 40
}

An async def handler, one def dependency and one async def dependency. The sync dependency landed on a worker thread; the async one and the handler both ran on MainThread.

This is genuinely good news, and it is the thing that makes incremental migration workable. A blocking legacy call does not force the whole endpoint to become def — put it behind a plain def dependency and Starlette threads that one piece while the handler stays on the loop. The cost is that those dependencies draw on the same 40-token capacity limiter as your def handlers do.

The Decision Rule

What the body doesDeclare itWhy
Awaits async clients end to endasync defYields on every wait; scales with the loop
Contains any blocking line you cannot removedefStarlette threads it; blocking is harmless there
Blocking, but you need async resources tooasync def + a def dependencyEach callable dispatches on its own terms
CPU-bound for more than a few millisecondsdef, then a process pool or a queueThreads do not help under the GIL
Nothing at all, and it is very hotasync defSkips the threadpool hop

The rule underneath the table: declare def unless you can point at every await in the body. Uncertainty should resolve toward def, because def degrades gracefully and async def does not.

Verification

A single request cannot tell the three cases apart — all of them take 100ms. Concurrency is the only discriminator, so the test has to fan out:

async def test_endpoint_does_not_serialise(client):
    """Ten concurrent 100ms requests should overlap, not queue."""
    started = time.perf_counter()
    await asyncio.gather(*[client.get("/profile") for _ in range(10)])
    elapsed = time.perf_counter() - started
    assert elapsed < 0.3, f"took {elapsed:.2f}s — the loop is being blocked"

Pick a threshold well above one unit of work and well below the serial total; the gap between 0.1s and 2.0s leaves plenty of room, so this test is not flaky. Add one for every endpoint whose I/O layer you do not control, and it will catch the day a dependency upgrade swaps an async call for a sync one.

In production, the signal is the shape rather than the value: plot p50 latency against concurrent requests in flight. A correct handler's line is flat. A blocked one climbs linearly, and Fixing Blocking Calls in Async Routes turns that shape into a specific line of code.

Trade-offs and When Not To

def everywhere is a defensible starting position. For a service whose entire data layer is synchronous, declaring every handler def is correct, boring and fast enough. You pay a sub-millisecond hop per request and a ceiling of 40 concurrent handlers. Do not rewrite it for a benchmark you have not run.

async def everywhere is not. It is only correct if the whole stack is awaitable — the database driver, the HTTP client, the cache client. One synchronous library anywhere in the request path undoes it, and the failure is invisible until you have load.

Mixing is normal, not a smell. Adjacent routes in the same router can differ. The declaration describes one function's body, and different functions do different things.

CPU-bound work does not belong in either. Threads do not sidestep the GIL, so a def handler doing heavy computation occupies a thread without gaining parallelism. Move it to a process pool or off the request entirely with Background Task Processing.

FAQ

Is async def always faster than def in FastAPI? No. Measured at concurrency 20 with a 100ms unit of work, async def awaiting async I/O finished in 0.10s and plain def doing blocking work also finished in 0.10s, while async def containing the same blocking call took 2.00s. The declaration only helps when the body actually awaits.

What does declaring a handler def actually cost? For a handler that does no I/O at all, the threadpool hop measured as sub-millisecond and between one and three times the async def baseline depending on the machine and its load. Absolute figures and even the exact ratio move between runs, so treat the direction as the finding: negligible next to any real I/O, and only worth thinking about on very hot trivial endpoints.

Why can a def handler not await anything? Because it does not run on the event loop. A def handler executes on a thread named AnyIO worker thread, where asyncio.get_running_loop raises RuntimeError: no running event loop. There is no loop on that thread to await against, which is why async clients and async sessions are unavailable there.

Do dependencies follow the handler's declaration? No. Each dependency is dispatched on its own. A plain def dependency runs on a worker thread even when the handler is async def, and an async def dependency runs on the loop even when the handler is plain def. You can mix them freely in one route.

How many threads does FastAPI use for def handlers? Starlette dispatches them through an AnyIO capacity limiter whose default is 40 tokens per event loop. That is a cap on concurrent offloaded calls rather than a pre-spawned pool, so the 41st concurrent def handler waits for a token before it starts.

Should I convert an existing def endpoint to async def? Only if you convert every blocking call inside it at the same time. A half-converted handler is strictly worse than the def version it replaced, because def was already safe and async def with a blocking line is the one combination that stalls every other request on the worker.