Backend12 min read

How FastAPI Actually Works Behind the Scenes

BY MANISH KUMAR
June 1, 2026

I've been using FastAPI for a while. Type hints, docs appear, validation works — it felt like magic. So I dug into how it actually works.

The Starting Point: ASGI

Before FastAPI, there was WSGI (Web Server Gateway Interface). Django and Flask use it. Here's how it works: your server (like Gunicorn) gets a request, calls your Python app with an environ dict and a start_response callback, and your app returns bytes. Simple. Blocking. One request at a time per worker.

ASGI (Asynchronous Server Gateway Interface) is the newer one. It handles multiple requests concurrently in a single process — async Python. The interface looks like this:

async def app(scope, receive, send):
    # scope = connection info (method, path, headers, etc.)
    # receive = coroutine to get incoming messages
    # send = coroutine to send outgoing messages
    await send({
        "type": "http.response.start",
        "status": 200,
        "headers": [(b"content-type", b"text/plain")],
    })
    await send({
        "type": "http.response.body",
        "body": b"Hello, world!",
    })

That's it. That's the entire spec. Your app is just an async function that takes three arguments and sends two messages. Think of it like having a smart receptionist who can handle multiple calls at once — when someone is waiting on hold or the line is busy, they switch to another caller.

When you run uvicorn main:app, Uvicorn opens a socket, listens for connections, and for each incoming request it calls your app with scope, receive, and send.

Enter Starlette

FastAPI sits on top of Starlette.

Starlette takes the raw ASGI interface and wraps it — routing, middleware, request/response objects, WebSocket support, background tasks.

Essentially, Starlette gives you this:

from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

async def homepage(request):
    return JSONResponse({"hello": "world"})

app = Starlette(routes=[
    Route("/", endpoint=homepage),
])

Now instead of manually parsing the scope dict and building response messages, you get a request object with .method, .url, .headers, .json(), and you just return a response. Starlette handles the ASGI wiring.

FastAPI is Starlette with extra features — validation (Pydantic), OpenAPI generation, dependency injection.

Where FastAPI Starts: The Type Hints

Say you write this:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

@app.post("/items")
async def create_item(item: Item):
    return {"message": f"{item.name} costs {item.price}"}

When Python runs this file, @app.post("/items") registers your function as a route handler. At this point FastAPI doesn't know what Item is — it just sees a type annotation.

At startup time, when you run uvicorn main:app, FastAPI looks at every registered route and inspects the function's type annotations using Python's inspect module.

For create_item, it finds that item has a type hint of Item. It checks if Item is a Pydantic BaseModel subclass. If yes, it knows — "this is a request body parameter."

It builds an internal schema for each parameter — where it comes from, what type, default value, validators.

Pydantic: The Validation Engine

Pydantic does the actual work. When a request comes in and FastAPI determines item comes from the JSON body:

  1. Read raw bytes from the body
  2. Parse as JSON → Python dict
  3. Pass to Item(**data)

When you call Item(**data), Pydantic takes the dict and for each field:

  • Pulls the value (or uses default)
  • Checks type matches
  • Runs validators
  • Coerces types where possible
  • Returns errors if anything fails

If validation passes, you get a typed Item object. If it fails, Pydantic returns errors. FastAPI returns a 422 response automatically.

Compare with Flask:

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/items", methods=["POST"])
def create_item():
    data = request.get_json()
    if not data:
        return jsonify({"error": "Invalid JSON"}), 400
    name = data.get("name")
    price = data.get("price")
    if not name or not price:
        return jsonify({"error": "Missing fields"}), 400
    if not isinstance(price, (int, float)):
        return jsonify({"error": "Price must be a number"}), 400
    return jsonify({"message": f"{name} costs {price}"})

Eight lines of boilerplate for basic validation. FastAPI version: zero.

The ASGI Request Lifecycle

Here's what happens when a request hits:

Browser  Internet  Uvicorn (socket)  ASGI scope  Starlette Router  FastAPI route handler

Step 1: Uvicorn accepts the TCP connection.

Uvicorn runs an event loop (based on uvloop). It uses asyncio.start_server to accept TCP connections. Each connection is handled as an asyncio task.

Step 2: Uvicorn parses the HTTP request.

It reads bytes from the socket, parses the HTTP request line and headers, builds the ASGI scope dict:

scope = {
    "type": "http",
    "method": "POST",
    "path": "/items",
    "headers": [(b"content-type", b"application/json"), ...],
    # ... more stuff
}

Step 3: Uvicorn calls your app with the scope.

It calls app(scope, receive, send) where app is your FastAPI instance. This is an async call — it doesn't block the event loop.

Step 4: Starlette routes the request.

FastAPI delegates to Starlette's router, which matches the path against registered routes. It extracts path parameters (like {item_id} from /items/{item_id}).

Step 5: FastAPI resolves dependencies.

If your endpoint has dependencies (Depends()), FastAPI resolves them now. Dependencies can themselves have dependencies — FastAPI walks the entire tree, resolves them in order, and injects them into your function. This is basically just calling functions and caching the results where appropriate.

Step 6: FastAPI parses and validates the request.

For each parameter in your function signature, FastAPI determines its origin:

  • If it's a Pydantic model → parse JSON body and validate
  • If it has Query(), Path(), Header(), Cookie() → extract from the right place
  • If it's a simple type like int or str with no annotation → treat as query parameter
  • If it's Request or Response → inject the Starlette objects directly

Step 7: Your function runs.

Now your actual code executes. It's just a Python function doing Python things — calling a database, processing data, whatever. This part is yours.

Step 8: FastAPI serializes the response.

Whatever you return, FastAPI runs it through a serializer. If you return a dict, it calls json.dumps() on it. If you return a Pydantic model, it calls .model_dump() (or .dict() in older versions) and then serializes that. If you return a Starlette Response object, it passes it through as-is.

Step 9: Starlette sends the response.

Starlette takes the serialized data, wraps it in the ASGI response format:

await send({
    "type": "http.response.start",
    "status": 200,
    "headers": [(b"content-type", b"application/json")],
})
await send({
    "type": "http.response.body",
    "body": b'{"message": "Laptop costs 999.99"}',
})

Step 10: Uvicorn writes bytes to the socket.

Uvicorn gets the ASGI response messages, formats them as HTTP/1.1 (or HTTP/2 if configured), and writes the bytes to the TCP socket. The browser reads them and you see your response.

The OpenAPI Generation

FastAPI automatically generates /docs with Swagger UI.

When FastAPI inspects your function signatures at startup, it builds a data structure representing every endpoint — the path, HTTP method, parameters (name, type, location, validation rules), request body schema, and response schema.

Then it feeds everything into Pydantic's JSON Schema generator. Pydantic models produce JSON Schema definitions. FastAPI packages them into an OpenAPI 3.0 spec and serves it at /openapi.json.

When you visit /docs, Swagger UI fetches /openapi.json and renders it.

Dependency Injection — Not as Scary as It Sounds

FastAPI's dependency injection sounds fancy but it's just calling functions.

from fastapi import Depends

def get_db():
    db = Database()
    return db

@app.get("/items")
def read_items(db = Depends(get_db)):
    return db.query_all()

When FastAPI sees Depends(get_db), it makes a note: "before calling read_items, I need to call get_db and pass its return value to db."

It literally does this at request time:

db = get_db()
result = read_items(db)

That's it. The magic is that Depends can be nested — a dependency can itself have dependencies, and FastAPI resolves the whole tree. But underneath, it's just calling functions in the right order and passing values around.

You can also cache results:

def get_db():
    db = Database()
    return db

def get_user(db = Depends(get_db)):
    return db.get_current_user()

@app.get("/items")
def read_items(db = Depends(get_db), user = Depends(get_user)):
    # db is the same Database instance as in get_user
    # FastAPI reuses the same result within a single request
    return db.query_all()

This is called "request-scoped" caching. FastAPI caches dependency results per request, so if multiple dependencies ask for the database, they all get the same instance.

What's Actually Async

Here's something that tripped me up: not everything in FastAPI is async. If you define a route with def instead of async def, FastAPI runs it in a thread pool. This is important because:

@app.get("/sync")  # Runs in a thread pool
def read_sync():
    import time
    time.sleep(5)  # This is fine  it's in a separate thread
    return {"data": "done"}

@app.get("/async")  # Runs on the main event loop
async def read_async():
    import time
    time.sleep(5)  # BAD! This blocks the entire server
    return {"data": "done"}

FastAPI detects the difference from the async keyword. async def → run directly on the event loop. def → run in a thread pool. This is smart because it means you don't have to rewrite your entire codebase to be async — you can mix synchronous database drivers with async route handlers.

If you need to do async I/O inside an async function, use libraries like httpx or asyncpg instead of requests or psycopg2:

import httpx
import asyncpg

@app.get("/async-request")
async def make_request():
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.example.com/data")
    return response.json()

Don't use time.sleep() in async functions. Use asyncio.sleep() instead:

import asyncio

@app.get("/wait")
async def wait_a_bit():
    await asyncio.sleep(5)  # This is correct
    return {"done": True}

The Stuff You Don't See

FastAPI does a bunch of things silently that you'd have to wire up manually in other frameworks:

  • CORS middlewareapp.add_middleware(CORSMiddleware, ...) — FastAPI doesn't add it by default, but when you do, it intercepts every response and adds the right headers
  • GZip compression — same deal with GZipMiddleware — it compresses responses for clients that support it
  • Exception handlers — FastAPI converts Python exceptions to HTTP responses with proper status codes
  • Background tasksBackgroundTasks lets you queue work to run after the response is sent, like sending an email after a user signs up

Most of this is Starlette. FastAPI just wraps it well.

Request Lifecycle in Code

Here's what happens inside FastAPI for a single request:

# 1. Request arrives, matched to route
@app.post("/items/{item_id}")
async def update_item(item_id: int, item: Item, db = Depends(get_db)):
    # 2. Path parameter (item_id) is validated as int
    # 3. Request body is parsed and validated as Item (Pydantic)
    # 4. Dependencies are resolved (db is obtained via get_db)
    # 5. Your code runs here
    updated = db.update(item_id, item)
    # 6. Return value is serialized
    return updated

FastAPI handles steps 2-6. You write steps 5.

Without Starlette FastAPI is nothing. Without Pydantic it's just Starlette with nicer syntax. FastAPI glues them together.

You write types once and get validation, docs, serialization. That's the whole point.