Skip to content

Routing

Routes are registered with a decorator per method.

@app.get("/users")
async def list_users(_: Request): ...

@app.post("/users")
async def create_user(_: Request): ...

@app.put("/users/{user_id}")
async def replace_user(_: Request, user_id: int): ...

@app.delete("/users/{user_id}")
async def delete_user(_: Request, user_id: int): ...

app.route(method, path) handles anything else.

Every handler takes the request as its first argument. Name it _ when you do not need it; it is still passed.

Path parameters

Declare them in the path, type them in the signature.

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

{*name} captures the rest of the path, slashes included.

@app.get("/files/{*rest}")
async def get_file(_: Request, rest: str):
    return {"path": rest}

A catch-all is client input, not a path

{*rest} hands over what the client sent. .. is not stripped, in either its plain or its encoded form. A handler that joins it to a directory must sanitise it first — this is a router, not a file server.

Percent-encoding

Path parameters are percent-decoded before they are coerced, the same as query parameters.

GET /items/a%20b        ->  "a b"
GET /items/a%2Fb        ->  "a/b"
GET /items/caf%C3%A9    ->  "café"
GET /items/a+b          ->  "a+b"

+ is a space only in a query string; in a path it is a literal plus.

Routing happens against the raw path, so a %2F that decodes to a slash cannot change which route matched. Bytes that are not valid UTF-8 become replacement characters rather than an error, which is what the query side has always done.

Supported annotations are str, int, float, bool, uuid.UUID, datetime.date and datetime.datetime. Anything else is a TypeError at import time, as is a path parameter the handler does not accept, or a handler argument that names a path segment that does not exist.

Coercion happens in Rust

A path parameter is parsed and validated on the tokio thread that read the request, on a radix tree built per method. A request that cannot possibly succeed is answered without waking a Python worker at all.

GET    /users/42    ->  200  {"user_id": 42}
GET    /users/abc   ->  422  {"detail": [{"type": "path_param_parsing", ...}]}
DELETE /users/42    ->  405  Allow: GET
HEAD   /users/42    ->  200  headers only, Content-Length as GET would send

The three richer types are validated with real calendar and format checking, so /events/2026-02-30 is a 422 and never becomes an exception inside a handler.

Validated in Rust, canonicalised in Rust

Rust accepts ISO forms that Python's own constructors reject — 2026-01-02T03:04:05Z, or a UUID without hyphens. So whatever Rust validates, it also rewrites into the form Python will accept before building the object. Input already judged valid must never raise on the other side and turn into a 500.

Matching order

Static segments win over dynamic ones, and a catch-all is the last resort, so /users/me and /users/{user_id} can coexist and the literal wins. This is the radix tree's own precedence, not a scan down a list of patterns, which is why routing cost does not grow with the number of routes.

Methods you do not write

HEAD is answered wherever GET is: the same handler runs, the headers it would have produced are sent, including Content-Length, and the body is dropped. A method with no route on an existing path returns 405 with an Allow header listing what that path does accept. A plain GET to a WebSocket route returns 426.