A route is a method plus a path
Byte: Every train has a platform. The route is the sign above it.
JSON body
{"age": "old"}
SignupRequest model
types checked
invalid → 422, handler skipped
your handler
only runs if valid
A route tells FastAPI which function answers which request. It is the method (GET, POST…) plus the path (/trains). The decorator above a function registers it.
When a request arrives, FastAPI looks for a route whose method and path match. No match on the path answers 404; the path matches but the method doesn't, 405.
from fastapi import FastAPIapp = FastAPI()@app.get("/trains") # GET /trains lands hereasync def list_trains():return {"trains": [101, 204]}@app.post("/trains", status_code=201) # POST /trains lands hereasync def add_train():return {"id": 350}
Quick check: With only the routes above, what does DELETE /trains answer?
- 404
- 405
- 200
The path /trains exists, but no route listens for DELETE on it: 405 Method Not Allowed.
Path parameters
Byte: Curly braces catch a piece of the path.
Write {train_id} in the path and add a parameter with the same name. FastAPI hands you that piece of the URL.
The type hint is a check, too: train_id: int turns "7" into 7, and /trains/abc bounces with 422 before your code runs.
@app.get("/trains/{train_id}")async def get_train(train_id: int):return {"train": train_id}# GET /trains/7 -> 200 {"train": 7}# GET /trains/abc -> 422 (not an int)
Quick check: Which route catches GET /stations/central/trains?
@app.get("/stations/{name}/trains")@app.get("/stations/trains/{name}")
The braces sit exactly where the changing part of the path is.
Query parameters
Byte: Everything after the question mark is optional extras.
A function parameter that is not in the path becomes a query parameter: /departures?line=red&limit=5.
Give it a default and it becomes optional. line: str | None = None means "maybe not sent"; limit: int = 10 means "10 unless told otherwise". Types still apply: ?limit=abc bounces with 422.
@app.get("/departures")async def departures(line: str | None = None, limit: int = 10):return board(line, limit)# /departures -> line=None, limit=10# /departures?line=red -> line="red", limit=10# /departures?limit=abc -> 422
Quick check: async def search(q: str): with no default. What does GET /search answer?
- 200 with q = None
- 422: q is required
No default means required. Add = None (and allow None) to make it optional.
Order matters
Byte: The first matching sign wins. Fixed signs go first.
FastAPI checks routes top to bottom and takes the first one that matches. /trains/{train_id} also matches /trains/next, so if it comes first, next is treated as a train ID (and bounces with 422 because it isn't an int).
Declare fixed paths like /trains/next before paths with parameters.
@app.get("/trains/next") # fixed path firstasync def next_train():return {"route": "next"}@app.get("/trains/{train_id}") # then the parameter pathasync def get_train(train_id: int):return {"route": "one", "id": train_id}
Quick check: If the {train_id} route were declared first, what would GET /trains/next answer?
200 {"route": "next"}- 422
"next" reaches the int route first and fails the type check.