Learnixo

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 BaseModelquery parameter
  • If the type is a Pydantic BaseModel subclass → 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:

Python
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:

Python
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:

Python
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:

Python
@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()

Python
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:

Python
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:

Python
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:

Python
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():

Python
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():

Python
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:

Python
# 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:

Python
# 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 BaseModel parameter is always read from the JSON request body
  • Use Path(), Query(), Header(), and Body() to add constraints and documentation metadata
  • exclude_unset=True in model_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.