Routing is how FastAPI maps an incoming HTTP request (method + URL path) to a Python function. In FastAPI, those functions are called path operations (also commonly “endpoints”). A REST-style API typically exposes resources (like items) through predictable URLs and HTTP methods: GET to read, POST to create, PUT/PATCH to update, and DELETE to remove.
Path operations: GET/POST/PUT/PATCH/DELETE
A path operation is defined by decorating a function with the HTTP method and path. The function’s parameters become inputs (path params, query params, headers, body) depending on how you declare them.
from fastapi import FastAPI, status
app = FastAPI()
@app.get("/health")
def health_check():
return {"status": "ok"}
@app.post("/echo", status_code=status.HTTP_201_CREATED)
def echo(payload: dict):
# payload is parsed from JSON request body
return {"received": payload}Key ideas:
@app.get(...),@app.post(...), etc. define the method.status_code=...controls the default response status for success.- Returning a
dict(or list) produces a JSON response automatically.
Path parameters
Path parameters are variables embedded in the URL. They are required and are strongly typed by your function annotations. If a value can’t be converted to the declared type, FastAPI returns a 422 validation error.
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"item_id": item_id}Try requests like:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
GET /items/123→{"item_id": 123}GET /items/abc→ validation error (becauseabcis not anint)
Query parameters (pagination and filtering patterns)
Query parameters come after ? in the URL and are typically optional. In FastAPI, any function parameter that is not part of the path is treated as a query parameter by default (unless it’s a body model or explicitly declared otherwise).
Pagination with limit/offset
A common REST pattern is limit (page size) and offset (how many records to skip). You can give defaults and types to get validation for free.
from fastapi import FastAPI
app = FastAPI()
FAKE_ITEMS = [
{"id": 1, "name": "Keyboard"},
{"id": 2, "name": "Mouse"},
{"id": 3, "name": "Monitor"},
{"id": 4, "name": "Laptop"},
]
@app.get("/items")
def list_items(limit: int = 20, offset: int = 0):
return {
"limit": limit,
"offset": offset,
"data": FAKE_ITEMS[offset : offset + limit],
"total": len(FAKE_ITEMS),
}Example calls:
GET /items(defaults:limit=20,offset=0)GET /items?limit=2&offset=2returns items 3–4
Filtering with optional query parameters
Filtering is often implemented by accepting optional query parameters (e.g., q for search, min_price, in_stock). If a query parameter is omitted, it remains None and you can skip that filter.
from typing import Optional
from fastapi import FastAPI
app = FastAPI()
ITEMS = [
{"id": 1, "name": "Keyboard", "price": 50, "in_stock": True},
{"id": 2, "name": "Mouse", "price": 25, "in_stock": True},
{"id": 3, "name": "Monitor", "price": 200, "in_stock": False},
]
@app.get("/catalog")
def catalog(q: Optional[str] = None, min_price: Optional[int] = None, in_stock: Optional[bool] = None):
results = ITEMS
if q:
q_lower = q.lower()
results = [i for i in results if q_lower in i["name"].lower()]
if min_price is not None:
results = [i for i in results if i["price"] >= min_price]
if in_stock is not None:
results = [i for i in results if i["in_stock"] == in_stock]
return {"count": len(results), "data": results}Example calls:
GET /catalog?q=mouseGET /catalog?min_price=100GET /catalog?in_stock=true&q=key
Grouping routes with APIRouter (prefixes and tags)
As your API grows, putting every endpoint in a single file becomes hard to maintain. APIRouter lets you group endpoints by feature (items, users, orders) and then include them into the main app. This also enables consistent URL prefixes (like /api or /items) and documentation grouping via tags.
Conceptually:
- Create a router per feature (e.g.,
items/router.py). - Use
prefixto avoid repeating path segments. - Use
tagsto group endpoints in the OpenAPI docs. - Include the router in the main application with
app.include_router(...).
from fastapi import FastAPI
from items.router import router as items_router
app = FastAPI()
app.include_router(items_router, prefix="/api", tags=["items"])With this pattern, if the router defines @router.get("/"), the final path becomes /api/ (plus any router-level prefix you set inside the router). You can apply prefixes either at include-time, router creation-time, or both.
Returning JSON and controlling status codes
FastAPI will serialize common Python types to JSON automatically (dicts, lists, strings, numbers). You control status codes in two main ways:
- Set a default success status on the decorator:
@router.post(..., status_code=201). - Return a custom response with a specific status code (useful for non-standard cases).
from fastapi import APIRouter, status
from fastapi.responses import JSONResponse
router = APIRouter()
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
# If deletion succeeds, return nothing for 204
return
@router.get("/teapot")
def teapot():
return JSONResponse(status_code=418, content={"detail": "I am a teapot"})Practical guidance:
- Use
201 Createdfor successful creation. - Use
204 No Contentfor successful deletion when you don’t return a body. - Use consistent error shapes (often
{"detail": ...}) for client-friendly APIs.
Step-by-step: build an items feature module with a router
This section demonstrates a small but realistic router with multiple REST endpoints, including pagination, filtering, and status code control. To keep the focus on routing, we’ll use an in-memory “database” (a list) and simple ID generation.
1) Create the router and in-memory store
from typing import Optional
from fastapi import APIRouter, HTTPException, status
router = APIRouter(prefix="/items")
# In-memory store (replace with a real database later)
ITEMS = [
{"id": 1, "name": "Keyboard", "price": 50, "in_stock": True},
{"id": 2, "name": "Mouse", "price": 25, "in_stock": True},
{"id": 3, "name": "Monitor", "price": 200, "in_stock": False},
]
NEXT_ID = 42) List items (GET) with pagination and filtering
@router.get("/")
def list_items(
limit: int = 20,
offset: int = 0,
q: Optional[str] = None,
in_stock: Optional[bool] = None,
):
results = ITEMS
if q:
q_lower = q.lower()
results = [i for i in results if q_lower in i["name"].lower()]
if in_stock is not None:
results = [i for i in results if i["in_stock"] == in_stock]
paged = results[offset : offset + limit]
return {
"limit": limit,
"offset": offset,
"count": len(paged),
"total": len(results),
"data": paged,
}Try:
GET /api/items?limit=2&offset=0GET /api/items?q=moGET /api/items?in_stock=true
3) Retrieve one item by ID (GET with path parameter)
@router.get("/{item_id}")
def get_item(item_id: int):
for item in ITEMS:
if item["id"] == item_id:
return item
raise HTTPException(status_code=404, detail="Item not found")4) Create an item (POST) and return 201
Here we accept a JSON body as a plain dict to keep the example focused on routing. (In a production API, you will typically use Pydantic models for request/response schemas.)
@router.post("/", status_code=status.HTTP_201_CREATED)
def create_item(payload: dict):
global NEXT_ID
name = payload.get("name")
if not name:
raise HTTPException(status_code=422, detail="'name' is required")
price = payload.get("price", 0)
in_stock = payload.get("in_stock", True)
item = {"id": NEXT_ID, "name": name, "price": price, "in_stock": in_stock}
NEXT_ID += 1
ITEMS.append(item)
return itemExample request body:
{"name": "Webcam", "price": 80, "in_stock": true}5) Replace an item (PUT)
PUT is commonly used to replace the full resource. If you want to enforce “full replacement,” validate that required fields are present.
@router.put("/{item_id}")
def replace_item(item_id: int, payload: dict):
name = payload.get("name")
if not name:
raise HTTPException(status_code=422, detail="'name' is required")
price = payload.get("price")
in_stock = payload.get("in_stock")
if price is None or in_stock is None:
raise HTTPException(status_code=422, detail="'price' and 'in_stock' are required")
for idx, item in enumerate(ITEMS):
if item["id"] == item_id:
updated = {"id": item_id, "name": name, "price": price, "in_stock": in_stock}
ITEMS[idx] = updated
return updated
raise HTTPException(status_code=404, detail="Item not found")6) Partially update an item (PATCH)
PATCH updates only provided fields. A typical pattern is: read existing resource, merge changes, then save.
@router.patch("/{item_id}")
def update_item(item_id: int, payload: dict):
for idx, item in enumerate(ITEMS):
if item["id"] == item_id:
updated = item.copy()
if "name" in payload:
updated["name"] = payload["name"]
if "price" in payload:
updated["price"] = payload["price"]
if "in_stock" in payload:
updated["in_stock"] = payload["in_stock"]
ITEMS[idx] = updated
return updated
raise HTTPException(status_code=404, detail="Item not found")7) Delete an item (DELETE) with 204 No Content
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
for idx, item in enumerate(ITEMS):
if item["id"] == item_id:
del ITEMS[idx]
return
raise HTTPException(status_code=404, detail="Item not found")8) Include the router in your app with prefix and tags
In your main application module, include the router. You can apply an API-wide prefix (like /api) and documentation tags.
from fastapi import FastAPI
from items.router import router as items_router
app = FastAPI()
app.include_router(items_router, prefix="/api", tags=["items"])Resulting endpoints:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/items | List items (pagination/filtering) |
| GET | /api/items/{item_id} | Retrieve one item |
| POST | /api/items | Create item (201) |
| PUT | /api/items/{item_id} | Replace item |
| PATCH | /api/items/{item_id} | Partial update |
| DELETE | /api/items/{item_id} | Delete item (204) |