FULL STACK CITY

The Router Station · 6 min read

FastAPI routing: path parameters, query parameters and route order

How a route is a method plus a path, when to use path vs query parameters in FastAPI, and why route order matters.

A route is a method plus a path

Byte: Every train has a platform. The route is the sign above it.

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.

station.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/trains") # GET /trains lands here
async def list_trains():
return {"trains": [101, 204]}
@app.post("/trains", status_code=201) # POST /trains lands here
async 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.

station.py
@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.

station.py
@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.

station.py
@app.get("/trains/next") # fixed path first
async def next_train():
return {"route": "next"}
@app.get("/trains/{train_id}") # then the parameter path
async 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.