Inside FastAPI: How Requests, Validation and Async Really Work

What’s Actually Happening Inside FastAPI 

Most FastAPI tutorials show you how to slap a decorator on a function and get a working API in five minutes. That part is easy and it’s also the least interesting part of the framework. The interesting part -the part nobody explains until you go digging through the source -is what happens between the moment a request hits your server and the moment your function body starts executing. 

This post is that explanation. We’ll go from the socket all the way to your return statement and then come back out through validation and serialization. Along the way we’ll answer the question that trips up almost everyone at some point: when you write def versus async def, what does FastAPI actually do differently and why does mixing them up quietly wreck your app’s performance?

FastAPI doesn’t do the heavy lifting -it orchestrates 

The first thing worth internalizing is that FastAPI is not a web server and it’s barely even a web framework in the traditional sense. It’s a layer of ergonomics sitting on top of two other projects: 

  • Starlette handles the actual HTTP mechanics -routing, requests, responses, middleware, WebSocket support, the works. 
  • Pydantic handles data validation, parsing, and serialization based on Python type hints. 

FastAPI’s job is to glue these together and add the things developers actually want: automatic request validation from type hints, automatic response serialization, automatic OpenAPI/Swagger docs, and a dependency injection system. If you strip away the decorators, APIRouter, and Depends, what’s left underneath is a Starlette Router object with a bunch of Route instances registered on it. 

This matters because it explains a lot of behavior that seems mysterious. Why can FastAPI mount sub-applications? Because Starlette supports it. Why does exception handling work the way it does? Starlette. Why is there an ASGI app at the bottom of all this instead of WSGI? That’s the next piece. 

ASGI: the actual contract your code runs under 

Before FastAPI, before Starlette, there’s ASGI -the Asynchronous Server Gateway Interface. It’s the spec that replaced WSGI for async-capable Python web apps, and understanding it is the key to understanding everything about how requests flow through the system. 

A WSGI app is a single callable: app(environ, start_response). It gets called once per request, does its thing synchronously, and returns. There’s no room for async, no concept of holding a connection open, nothing. 

An ASGI app looks like this instead: 

async def app(scope, receive, send):

Three arguments, and it’s a coroutine. scope is a dict describing the connection -method, path, headers, whether it’s HTTP or a WebSocket, all of it. receive is an awaitable you call to pull the next event off the wire (like chunks of the request body). send is an awaitable you call to push events back out (status line, headers, body chunks). 

Every layer of FastAPI -the app itself, every middleware, every route -is ultimately just an ASGI callable wrapping another ASGI callable. It’s callables all the way down. When Uvicorn (the ASGI server that actually owns the socket and speaks HTTP) gets a connection, it builds a scope dict and calls your app with it. Your FastAPI() instance is itself an ASGI app; calling it kicks off a chain of nested calls through the middleware stack until it lands in Starlette’s router, which matches the path and method against registered routes and hands off to the specific endpoint machinery FastAPI built for that route. 

None of your @app.get(…) functions are ASGI apps themselves, by the way. FastAPI wraps each one in an adapter that knows how to pull the scope apart, extract path params, query params, headers, and body, run them through Pydantic, call your function, and shove the result back into an ASGI response. That adapter is where basically everything interesting happens. 

Following one request from start to finish 

Let’s trace a concrete request. Say you have:

@app.post("/orders/{order_id}/items")
async def add_item(order_id: int, item: Item, user: User = Depends(get_current_user)):
    ...

Here’s the actual sequence of events once a client hits that endpoint: 

Uvicorn accepts the connection and parses the HTTP request line and headers.

It builds the ASGI scope dict and starts feeding your application.

The request passes through the middleware stack.

Every middlewareyou’ve added (CORS, GZip, your own custom ones, Starlette’s ServerErrorMiddleware and Exception Middleware which FastAPI always installs) wraps the next layer. This is implemented as nested function calls, so the order you add middleware in determines the order requests pass through them -and the reverse order for responses, since it’s unwinding the same call stack. 

The router matches the path.

Starlette’s router walks its list of compiled routes checking for a regex match against the path, in registration order. This is worth remembering: route matching is order-dependent. If you register/orders/new after/orders/{order_id} , the second one will swallow requests meant for the first, because {order_id} happily matches the literal string “new”. This bites people constantly.

FastAPI’sendpoint wrapper takes over. 

This is where the framework’s own logic starts. It needs to figure out, for every parameter in your function signature, where that value should come from -path, query, header, cookie, body, or a dependency. It figured this out once already, at startup, by inspecting your function’s signature and type annotations, and cached the result. It’s not re-parsing your function signature on every request; that would be absurd. What happens per-request is just looking up values from the incoming scope according to that pre-computed plan. 

Dependencies get resolved.

Depends(get_current_user) triggersFastAPI to call get_current_user, which might itself have dependencies, which get resolved first, recursively, forming a dependency graph that’s flattened and resolved depth-first. Dependencies declared with the same “identity” within a single request are cached -call the same dependency twice in one request’s graph and it only actually executes once, with the second reference reusing the cached return value. This is why dependencies are a genuinely good place to put things like “fetch the current user from a token,” even if three different parts of your dependency tree need that user. 

Path and query parameters get validated.

order_id: intisn’t just a type hint for your editor -FastAPI uses it to build a Pydantic field, and the raw string “42” pulled out of the URL path gets coerced and validated against it. Fail that, and the client gets a 422 before your function ever runs. 

The request body gets validated.

item: Item meansFastAPI expects a JSON body, reads and parses it, and validates it against your Pydantic model. This is a genuinely heavy step relative to everything else happening -JSON parsing plus a full Pydantic validation pass -which is part of why Pydantic v2’s Rust core made such a visible difference to FastAPI’s benchmarks when it shipped. 

Your function finally runs

now with fully-typed, validated Python objects for every parameter.

The return value gets serialized.

If you declared aresponse_model, your return value gets filtered and re-validated against it -this is also where fields get dropped if they’re not in the response model, which is a deliberate security feature, not a side effect. Then it’s serialized to JSON. 

The response travels back out through the middleware stack, in reverse, and Uvicorn writes it to the socket.

That’s the full loop. Ten steps, most of which are invisible unless something goes wrong. Now let’s get into the part that actually confuses people. 

Sync versus async: what def and async def really mean here

This is the single most misunderstood part of FastAPI, so let’s be precise about it. 

FastAPI runs on top of an event loop (via Uvicorn, using either the standard asyncio loop or uvloop). An event loop runs one thing at a time. It can juggle thousands of concurrent connections, but only because those connections spend most of their time waiting -on a database, on a downstream API, on disk -and while one coroutine is waiting, the loop runs another. Nothing is running in parallel on the event loop itself; it’s cooperative multitasking on a single thread. 

That model works beautifully as long as nothing you write blocks the loop. The moment you call something synchronous and slow -time.sleep(), a blocking database driver, requests.get() -inside an async def route, you freeze the entire event loop. Every other request being handled by that worker process stops dead until your blocking call finishes. This is the single most common way people accidentally tank their FastAPI app’s throughput: they write async def, feel good about using “the async version,” and then call a synchronous ORM inside it. 

Here’s what FastAPI actually does with each style: 

If you write async def, FastAPI assumes you know what you’re doing and awaits your function directly on the event loop. It trusts that everything inside is either non-blocking (using await for I/O) or fast enough not to matter. If that trust is misplaced, you get the freeze described above. 

If you write plain def, FastAPI does not run it on the event loop at all. It runs it in a separate thread, pulled from a thread pool, via Starlette’s run_in_threadpool (which itself is a thin wrapper around AnyIO’s threading primitives). The event loop dispatches the call to a worker thread and awaits the result of that dispatch, so the loop itself stays free to handle other requests while your synchronous function runs in its own thread. 

This is a deliberate design decision, and it’s actually pretty clever: it means you can write ordinary blocking code -a synchronous database call, a CPU-light blocking library -as a plain def route and FastAPI will keep it from blocking everyone else, without you doing anything special. The tradeoff is that thread pools are a limited resource (the default cap is 40 threads) and threads have real overhead compared to coroutines, so plain def isn’t a free pass to do something wildly expensive inside an endpoint. It’s a safety valve for blocking I/O, not a parallelism upgrade. 

So the actual decision tree looks like this: 

  • Doing async I/O (an async DB driver, httpx.AsyncClient, async file I/O)? Use async def and actually await those calls. 
  • Calling something synchronous and blocking that you can’t avoid (a sync ORM, a legacy library, blocking network calls)? Use plain def and let FastAPI’s threadpool absorb it. 
  • Doing something synchronous and blocking inside an async def function? This is the trap. You get none of the benefits and all of the downside -you’ve blocked the loop while getting no threadpool protection. 

The same logic applies to dependencies, by the way -a Depends() callable can be sync or async independently of the route function it’s injected into, and FastAPI handles each the same way: async dependencies run on the loop, sync ones get threadpooled. 

A quick mental model for the concurrency difference 

Imagine a single-lane road (the event loop) and a small parking lot with several attendants (the thread pool). 

An async def route that properly awaits I/O is like a car that pulls into a side spot to wait for something (say, a gate to open) and lets other cars use the lane while it waits -then rejoins traffic once the gate opens. Lots of cars can be “waiting” simultaneously this way, using very little actual road space. 

A plain def route is like a car that can’t do that side-spot trick, so instead it gets handed off to a parking attendant (a thread) who drives it around a private loop until it’s done, freeing up the main lane immediately. It works, but there are a limited number of attendants, and driving a car around burns more resources than just parking it in a side spot. 

An async def route with a blocking call inside is a car that stops dead in the middle of the one-lane road and refuses to move until it’s done -and now nobody behind it can pass, side spot or not. 

Dependency injection, a bit deeper 

FastAPI’s Depends() system is genuinely one of its best ideas, and it’s worth understanding why it’s not “just” a convenience wrapper. 

Every dependency is resolved by building a tree from your endpoint’s parameters. FastAPI walks this tree, and for each node, checks whether that exact callable (matched by identity, not just by name) has already been resolved earlier in this same request. If so, it reuses the cached value instead of calling it again -unless you explicitly pass use_cache=False. This caching is scoped to a single request; it’s not a memoization across requests, so there’s no stale-data risk between different clients’ calls. 

Dependencies that use yield instead of return are a special case worth calling out, because they behave like context managers: the code before yield runs before your endpoint, and the code after yield runs after your endpoint returns even if your endpoint raised an exception, the cleanup code after yield still runs, wrapped so it can catch and optionally re-raise. This is the standard pattern for things like “open a DB session, hand it to the endpoint, close it afterward no matter what happened.” 

Where OpenAPI comes from 

The interactive docs at /docs aren’t hand-maintained anywhere -they’re generated at runtime by walking every registered route, inspecting its Pydantic models and parameter types, and building an OpenAPI-compliant schema dict from that metadata. Pydantic models already know how to describe themselves as JSON Schema (this is a first-class Pydantic feature, not something FastAPI bolts on), so FastAPI is mostly just assembling pieces it already has lying around into the OpenAPI document format, then serving that document (and a Swagger UI or ReDoc page that renders it) at a couple of default routes. This is also why your type hints are not optional decoration -they are the actual source of truth the docs are built from. Get the types wrong, and the docs will confidently describe a contract that isn’t real. 

Why any of this matters in practice 

None of this changes how you write a basic CRUD endpoint. But it explains a handful of things that otherwise look like framework quirks: 

  • Why a synchronous database call inside async def can make your app slower under load than the same call in a plain def function. 
  • Why route registration order matters for overlapping path patterns. 
  • Why dependencies are the right place to centralize things like auth and DB sessions instead of calling them manually inside every function. 
  • Why validation errors happen before your function body runs, and response filtering happens after it -meaning your function’s return value is not actually what the client receives if you’ve declared a response_model. 
  • Why the docs page is never out of sync with your code, short of you lying in your type hints. 

FastAPI’s whole pitch is that type hints are not just documentation -they’re an executable specification that drives validation, serialization, and docs generation simultaneously. Once you see the ASGI/Starlette/Pydantic machinery underneath, the “magic” stops looking like magic and starts looking like a fairly disciplined pipeline: parse, resolve, validate, execute, serialize, respond. The framework’s real trick isn’t doing anything exotic -it’s being very consistent about applying that pipeline everywhere, so you rarely have to think about it. 

Related Searches

Related Solutions