Chapter 2: how FastAPI turns HTTP requests into typed Python calls. We cover path operations, every parameter source, routers, includes, and the small pieces that make routing feel ergonomic.

Path operations

from fastapi import FastAPI

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"id": user_id}

@app.get, @app.post, @app.put, @app.patch, @app.delete, @app.head, @app.options, @app.trace. There’s also @app.api_route(path, methods=["GET", "POST"]).

The decorator returns the original function. Test it directly without the framework:

result = await get_user(user_id=1)

Path parameters

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    ...
  • {item_id} is captured from the URL.
  • item_id: int triggers validation: /items/abc → 422.
  • Types: int, float, str, bool, UUID, custom Pydantic-supported types.

For paths that contain slashes:

@app.get("/files/{file_path:path}")
async def read_file(file_path: str):
    ...

:path matches /. Useful for static-like resources.

Query parameters

Anything in the function signature without a path placeholder becomes a query parameter:

@app.get("/search")
async def search(q: str, limit: int = 20, offset: int = 0):
    ...

/search?q=foo&limit=10&offset=20.

Optional with None:

async def search(q: str | None = None):
    ...

For more control, use Query:

from fastapi import Query

async def search(
    q: str | None = Query(None, min_length=2, max_length=100, regex="^[a-z]+$"),
    tags: list[str] = Query(default_factory=list, description="filter tags"),
):
    ...

Query lets you set validation, examples, deprecation, alias.

Body parameters

Pydantic models become JSON bodies:

from pydantic import BaseModel

class UserIn(BaseModel):
    email: str
    name: str

@app.post("/users")
async def create_user(user: UserIn):
    ...

The framework reads the request body, parses JSON, validates against UserIn, passes the instance.

For multiple body params:

@app.put("/users/{id}")
async def update_user(id: int, user: UserIn, importance: int = Body(...)):
    ...

By default with multiple body params, FastAPI wraps them: {"user": {...}, "importance": 5}. Use Body(embed=True) on a single body param to wrap it the same way.

Header parameters

from fastapi import Header

@app.get("/items")
async def list_items(user_agent: str | None = Header(None)):
    ...

Underscores in Python become hyphens in HTTP. user_agentUser-Agent.

For convert_underscores=False if you really need underscores in the header.

from fastapi import Cookie

@app.get("/")
async def home(session: str | None = Cookie(None)):
    ...

Read named cookies. To set cookies: Response.set_cookie(...) or middleware.

Form data

from fastapi import Form

@app.post("/login")
async def login(username: str = Form(...), password: str = Form(...)):
    ...

Requires python-multipart installed. Used for HTML forms / OAuth2 password flow.

File uploads

from fastapi import File, UploadFile

@app.post("/upload")
async def upload(file: UploadFile):
    contents = await file.read()
    return {"size": len(contents), "type": file.content_type, "name": file.filename}

UploadFile is preferred over bytes/File(...):

  • It streams (doesn’t load whole file).
  • It exposes file-like API.
  • Works for big files.

For multiple files: files: list[UploadFile].

Routers

For larger apps:

# api/users.py
from fastapi import APIRouter

router = APIRouter(prefix="/users", tags=["users"])

@router.get("/")
async def list_users(): ...

@router.get("/{id}")
async def get_user(id: int): ...

# main.py
from fastapi import FastAPI
from .api import users

app = FastAPI()
app.include_router(users.router)

Routers compose. They can have prefixes, dependencies, tags, responses.

Include with prefix and dependencies

app.include_router(
    users.router,
    prefix="/api/v1",
    tags=["v1", "users"],
    dependencies=[Depends(verify_api_key)],
)

Every route under this include requires the dependency. Useful for auth gates per router.

Nested routers

api = APIRouter(prefix="/api/v1")
api.include_router(users.router)
api.include_router(posts.router)

app.include_router(api)

Nest as deep as your URL hierarchy needs.

Mounts

app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/admin", admin_app)  # another ASGI app entirely

Mounts let you delegate paths to other ASGI apps. Useful for static files, admin dashboards, separate sub-apps.

Path operation parameters

Decorators take many options:

@app.post(
    "/users",
    response_model=UserOut,
    status_code=201,
    tags=["users"],
    summary="Create a user",
    description="Long description...",
    response_description="The created user",
    responses={400: {"description": "Bad input"}, 409: {"description": "Conflict"}},
    deprecated=False,
    operation_id="create_user",
)
async def create_user(...): ...

These shape OpenAPI. summary, description, tags, responses are visible in Swagger UI.

Status codes

from fastapi import status

@app.post("/users", status_code=status.HTTP_201_CREATED)

Default is 200 for non-DELETE; 200 for DELETE returning a body or 204 for no body.

The Request object

When you need raw access:

from fastapi import Request

@app.post("/raw")
async def raw(request: Request):
    body = await request.body()
    headers = dict(request.headers)
    client_ip = request.client.host
    ...

For 95% of cases: declared parameters. Reach for Request only when you must.

Custom routing

For dynamic mount based on tenant:

from fastapi import APIRouter
from fastapi.routing import APIRoute

class TenantRoute(APIRoute):
    def get_route_handler(self):
        original = super().get_route_handler()
        async def custom(request):
            request.state.tenant = await load_tenant(request.headers["x-tenant"])
            return await original(request)
        return custom

router = APIRouter(route_class=TenantRoute)

Custom APIRoute subclasses let you wrap every handler. Powerful escape hatch.

URL generation

url = app.url_path_for("get_user", user_id=42)
# /users/42

Uses the function name (or name= arg on the decorator). Avoids hard-coded URL strings.

Request lifecycle (detailed)

For an incoming HTTP request:

1. ASGI server parses bytes; calls FastAPI.
2. CORS middleware (if applicable).
3. Custom middleware in registration order.
4. Routing: match path, method, prefix.
5. Path operation found; validate path params.
6. Dependency graph resolved (Chapter 5).
7. Body / query / header / cookie / form / file params read.
8. Handler coroutine awaited.
9. Return value passed to response_model serialization.
10. Response sent.
11. Background tasks fire (Chapter 9).
12. Middleware exits in reverse order.

Every step is hookable.

Common patterns

Versioned routes

v1 = APIRouter(prefix="/api/v1")
v2 = APIRouter(prefix="/api/v2")

v1.include_router(users_v1.router)
v2.include_router(users_v2.router)

app.include_router(v1)
app.include_router(v2)

Coexisting versions. See API Versioning .

Health endpoints

@app.get("/healthz", include_in_schema=False)
async def health():
    return {"status": "ok"}

@app.get("/ready", include_in_schema=False)
async def ready(db: AsyncSession = Depends(get_db)):
    await db.execute(text("SELECT 1"))
    return {"status": "ready"}

Liveness (just up) vs readiness (deps reachable). include_in_schema=False keeps them out of OpenAPI.

Request ID propagation

@app.middleware("http")
async def request_id_mw(request: Request, call_next):
    rid = request.headers.get("x-request-id") or str(uuid.uuid4())
    request.state.request_id = rid
    response = await call_next(request)
    response.headers["x-request-id"] = rid
    return response

Available throughout the request via request.state.request_id.

Common mistakes

1. Body params on GET

GET shouldn’t have a body. FastAPI allows it; intermediaries may strip it.

2. Catching all paths inadvertently

/{path:path} swallows everything; put it last.

3. Conflicting routers

Two routers with the same prefix mount on top of each other; the last one wins per path. Order matters.

4. Heavy work in middleware

Middleware runs every request. DB calls, big computation: avoid; use dependencies.

5. Ignoring name in routes

url_path_for needs route names. Default name is the function; rename routes get name=.

What’s next

Chapter 3 covers Pydantic models for input validation in depth.

Read this next


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .