FastAPI for AI Engineers · Lesson 4 of 12
Path, Query, and Body Parameters
How FastAPI Resolves Parameters
FastAPI uses a simple convention to determine where a parameter comes from:
- If the name appears in the path template (
{drug_id}) → path parameter - If the parameter has a plain Python type and no Pydantic
BaseModel→ query parameter - If the type is a Pydantic
BaseModelsubclass → request body - If the default is a
Header()instance → HTTP header - If the default is a
Cookie()instance → cookie
You declare them all in the same function signature — FastAPI works out the source automatically.
Path Parameters
Path parameters capture segments of the URL:
from fastapi import FastAPI, Path, HTTPException
app = FastAPI()
# Simple path parameter
@app.get("/drugs/{drug_id}")
async def get_drug(drug_id: int) -> dict:
return {"drug_id": drug_id}
# Multiple path parameters
@app.get("/categories/{category}/drugs/{drug_id}")
async def get_drug_in_category(category: str, drug_id: int) -> dict:
return {"category": category, "drug_id": drug_id}Validating path parameters with Path()
Path() adds constraints and documentation to path parameters:
from fastapi import Path
@app.get("/drugs/{drug_id}")
async def get_drug(
drug_id: int = Path(
...,
ge=1,
description="Unique numeric identifier for the drug",
examples=42,
)
) -> dict:
# drug_id is guaranteed >= 1 here
drug = await fetch_drug_from_db(drug_id)
if drug is None:
raise HTTPException(status_code=404, detail=f"Drug {drug_id} not found")
return drug
@app.get("/drugs/name/{name}")
async def get_drug_by_name(
name: str = Path(
...,
min_length=2,
max_length=200,
pattern=r"^[a-zA-Z0-9 \-\(\)]+$",
description="Drug name — alphanumeric with spaces and hyphens",
)
) -> dict:
return {"name": name}Literal path parameters
Use Literal when only specific values are valid:
from typing import Literal
@app.get("/drugs/{drug_class}/list")
async def list_drugs_by_class(
drug_class: Literal["nsaid", "antibiotic", "antidepressant", "antihypertensive"]
) -> dict:
return {"class": drug_class}FastAPI validates that the path segment matches one of the literals and returns 422 if it doesn't.
Query Parameters
Any function parameter that is not a path parameter and not a body model is treated as a query parameter:
@app.get("/drugs/search")
async def search_drugs(
q: str, # Required — no default
limit: int = 10, # Optional — default 10
offset: int = 0, # Optional — default 0
active_only: bool = True,
) -> dict:
# GET /drugs/search?q=ibuprofen&limit=5&offset=0
return {
"query": q,
"limit": limit,
"offset": offset,
"active_only": active_only,
}Validating query parameters with Query()
from fastapi import Query
@app.get("/drugs/search")
async def search_drugs(
q: str = Query(
...,
min_length=1,
max_length=200,
description="Search term — drug name or INN",
),
limit: int = Query(
default=10,
ge=1,
le=100,
description="Number of results to return",
),
offset: int = Query(
default=0,
ge=0,
description="Number of results to skip for pagination",
),
category: str | None = Query(
default=None,
description="Filter by drug category",
),
sort_by: Literal["name", "created_at", "relevance"] = Query(
default="relevance",
),
) -> dict:
return {
"q": q,
"limit": limit,
"offset": offset,
"category": category,
"sort_by": sort_by,
}List query parameters
Accept multiple values for the same query key:
from fastapi import Query
@app.get("/drugs/filter")
async def filter_drugs(
categories: list[str] = Query(default=[]),
# GET /drugs/filter?categories=nsaid&categories=antibiotic
) -> dict:
return {"categories": categories}Request Body
When a route function parameter is typed as a Pydantic BaseModel, FastAPI reads it from the JSON request body:
from pydantic import BaseModel, Field
class CreateDrugRequest(BaseModel):
name: str = Field(..., min_length=2, max_length=200)
inn: str | None = Field(default=None, description="International Nonproprietary Name")
category: str
description: str = Field(..., max_length=5000)
active: bool = True
@app.post("/drugs", status_code=201)
async def create_drug(drug: CreateDrugRequest) -> dict:
# drug is fully validated
saved = await save_drug_to_db(drug.model_dump())
return {"id": saved["id"], "name": saved["name"]}Multiple bodies
You can declare multiple body parameters. FastAPI wraps them in a JSON object using the parameter names as keys:
class DrugBody(BaseModel):
name: str
category: str
class PublisherBody(BaseModel):
name: str
country: str
@app.post("/drugs/with-publisher")
async def create_with_publisher(
drug: DrugBody,
publisher: PublisherBody,
) -> dict:
# Expects: {"drug": {...}, "publisher": {...}}
return {"drug": drug.model_dump(), "publisher": publisher.model_dump()}Body() for singular values
To include a primitive in the request body (alongside a model), use Body():
from fastapi import Body
@app.put("/drugs/{drug_id}")
async def update_drug(
drug_id: int,
drug: CreateDrugRequest,
updated_by: str = Body(..., description="Username of the editor"),
) -> dict:
# Expects body: {"drug": {...}, "updated_by": "asma"}
return {"drug_id": drug_id, "updated_by": updated_by}Header Parameters
Headers are declared with Header():
from fastapi import Header
@app.get("/drugs/{drug_id}")
async def get_drug(
drug_id: int,
authorization: str | None = Header(default=None),
x_request_id: str | None = Header(default=None),
accept_language: str = Header(default="en"),
) -> dict:
# FastAPI converts hyphen to underscore: X-Request-ID → x_request_id
return {
"drug_id": drug_id,
"request_id": x_request_id,
"language": accept_language,
}Note: FastAPI automatically converts HTTP header names (hyphenated, case-insensitive) to Python variable names (underscore-separated, lowercase). X-Request-ID becomes x_request_id.
Complete CRUD Endpoint Examples: Drug Information API
Here is a full CRUD API for a drug information service, showing all parameter types together:
# routers/drugs.py
from fastapi import APIRouter, HTTPException, Query, Path, Header, Body, status
from pydantic import BaseModel, Field, field_validator
from typing import Literal, Optional
import re
router = APIRouter(prefix="/drugs", tags=["drugs"])
# --- Simulated database ---
_drugs_db: dict[int, dict] = {}
_next_id = 1
DRUG_NAME_RE = re.compile(r"^[a-zA-Z0-9 \-\(\)\/]+$")
# --- Models ---
class DrugCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=200)
inn: Optional[str] = None
category: Literal["nsaid", "antibiotic", "antidepressant", "antihypertensive", "other"]
description: str = Field(..., min_length=10, max_length=5000)
active: bool = True
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not DRUG_NAME_RE.match(v):
raise ValueError("Drug name contains invalid characters.")
return v
class DrugUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=2, max_length=200)
category: Optional[Literal["nsaid", "antibiotic", "antidepressant", "antihypertensive", "other"]] = None
description: Optional[str] = Field(default=None, min_length=10, max_length=5000)
active: Optional[bool] = None
class DrugResponse(BaseModel):
id: int
name: str
inn: Optional[str]
category: str
description: str
active: bool
class DrugListResponse(BaseModel):
items: list[DrugResponse]
total: int
limit: int
offset: int
# --- CREATE ---
@router.post(
"/",
response_model=DrugResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new drug entry",
)
async def create_drug(
drug: DrugCreate,
x_request_id: Optional[str] = Header(default=None),
created_by: str = Header(default="anonymous"),
) -> DrugResponse:
global _next_id
record = {
"id": _next_id,
"created_by": created_by,
"request_id": x_request_id,
**drug.model_dump(),
}
_drugs_db[_next_id] = record
_next_id += 1
return DrugResponse(**record)
# --- READ ONE ---
@router.get(
"/{drug_id}",
response_model=DrugResponse,
summary="Get a single drug by ID",
)
async def get_drug(
drug_id: int = Path(
...,
ge=1,
description="Unique drug ID",
),
) -> DrugResponse:
record = _drugs_db.get(drug_id)
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Drug with id={drug_id} not found.",
)
return DrugResponse(**record)
# --- READ LIST / SEARCH ---
@router.get(
"/",
response_model=DrugListResponse,
summary="List and search drugs",
)
async def list_drugs(
q: Optional[str] = Query(
default=None,
min_length=1,
max_length=200,
description="Full-text search term",
),
category: Optional[str] = Query(default=None, description="Filter by category"),
active_only: bool = Query(default=True, description="Return only active drugs"),
limit: int = Query(default=10, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> DrugListResponse:
items = list(_drugs_db.values())
if active_only:
items = [d for d in items if d["active"]]
if category:
items = [d for d in items if d["category"] == category]
if q:
ql = q.lower()
items = [
d for d in items
if ql in d["name"].lower() or ql in d["description"].lower()
]
total = len(items)
page = items[offset : offset + limit]
return DrugListResponse(
items=[DrugResponse(**d) for d in page],
total=total,
limit=limit,
offset=offset,
)
# --- UPDATE ---
@router.patch(
"/{drug_id}",
response_model=DrugResponse,
summary="Partially update a drug",
)
async def update_drug(
drug_id: int = Path(..., ge=1),
updates: DrugUpdate = Body(...),
updated_by: str = Header(default="anonymous"),
) -> DrugResponse:
record = _drugs_db.get(drug_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Drug {drug_id} not found.")
patch = updates.model_dump(exclude_unset=True)
record.update(patch)
record["updated_by"] = updated_by
_drugs_db[drug_id] = record
return DrugResponse(**record)
# --- DELETE ---
@router.delete(
"/{drug_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a drug",
)
async def delete_drug(
drug_id: int = Path(..., ge=1),
) -> None:
if drug_id not in _drugs_db:
raise HTTPException(status_code=404, detail=f"Drug {drug_id} not found.")
del _drugs_db[drug_id]Mount the router:
# main.py
from fastapi import FastAPI
from routers.drugs import router as drugs_router
app = FastAPI(title="Drug Information API")
app.include_router(drugs_router)Parameter Validation Summary
| Source | Declared with | Validated with | Default |
|--------|-------------|---------------|---------|
| URL segment | Type annotation in path | Path(...) | — |
| Query string | Type annotation (no BaseModel) | Query(...) | Python default |
| Request body | Pydantic BaseModel | Model fields | — |
| HTTP header | Header(...) | Header(...) | Python default |
| Cookie | Cookie(...) | Cookie(...) | Python default |
Key Takeaways
- Path parameters are declared in the route string as
{name}and matched by function parameter name - Query parameters are any non-path, non-body parameters — they read from the URL query string
- A Pydantic
BaseModelparameter is always read from the JSON request body - Use
Path(),Query(),Header(), andBody()to add constraints and documentation metadata exclude_unset=Trueinmodel_dump()is essential for PATCH endpoints — it returns only the fields the client actually sent- FastAPI validates all parameters before your handler runs, returning 422 with structured error details on failure
Next lesson: Server-Sent Events and LLM token streaming.