FastAPI uses runtime type inspection (via typing, inspect and Pydantic)
to derive three things from your function signatures automatically:
- Where each parameter comes from (path, query, body, header).
- How to parse and validate the incoming value (int, str, Pydantic model).
- What the OpenAPI schema should look like.
@app.get("/items/{item_id}")
async def get_item(
item_id: int, # → path int, validated, documented as integer
q: str | None = None, # → optional query string, documented
item: Item, # → JSON body parsed into Pydantic model
):
...
Without type hints, FastAPI cannot infer any of this — parameters become untyped strings and no schema is generated.
Rule of thumb: in FastAPI, type annotations are not optional documentation — they are the mechanism that drives parsing, validation, and the API contract.
Give the parameter a default value of None and annotate it with
str | None (Python 3.10+) or Optional[str] (Python 3.9-).
from typing import Optional
@app.get("/items")
async def list_items(
q: str | None = None, # optional, defaults to None
limit: int = 10, # optional with a non-None default
offset: int = 0,
):
...
In the OpenAPI schema, parameters with defaults appear as required: false.
FastAPI only marks a query param required: true when there is no default.
Rule of thumb: = None makes any parameter optional; omit the default to make
it required.
Annotated[T, metadata] (from typing) attaches extra metadata to a type
without changing the type itself. FastAPI reads the metadata to configure
parameter behaviour — Query constraints, Body options, Depends() etc.
from typing import Annotated
from fastapi import Query, Path
@app.get("/items/{item_id}")
async def get_item(
item_id: Annotated[int, Path(ge=1)], # path param, must be >= 1
q: Annotated[str | None, Query(max_length=50)] = None,
):
...
This keeps type information separate from validation metadata and works better
with type checkers (mypy, pyright) than the old item_id: int = Path(ge=1) style.
Rule of thumb: prefer Annotated[T, Field(...)] style in new code — it separates
the type from the constraint and is more explicit about what each annotation means.
Annotate the parameter with a str (or int) Enum subclass. FastAPI validates
the incoming value against the enum members and documents the allowed values in
OpenAPI.
from enum import Enum
class Size(str, Enum):
small = "small"
medium = "medium"
large = "large"
@app.get("/items")
async def list_items(size: Size = Size.medium):
return {"size": size.value}
Inheriting from both str and Enum ensures the value is JSON-serialisable
as a string directly.
Rule of thumb: use str enums for path/query params, int enums for numeric
codes; always inherit from str/int so serialisation is automatic.
Annotate with list[str] (or List[str]) and use Query() to tell FastAPI
to collect multiple values for the same key.
from typing import Annotated
from fastapi import Query
@app.get("/items")
async def list_items(
tags: Annotated[list[str], Query()] = [],
):
return {"tags": tags}
# GET /items?tags=python&tags=fastapi → {"tags": ["python", "fastapi"]}
Without Query(), a bare list[str] annotation would be treated as a body
parameter by FastAPI.
Rule of thumb: always wrap list[T] query params with Query() to signal that
multiple same-key values should be collected into a list.
Annotate the parameter with Header(). FastAPI automatically converts the
parameter name from snake_case to the HTTP header's hyphen-case format.
from typing import Annotated
from fastapi import Header
@app.get("/items")
async def list_items(
x_token: Annotated[str | None, Header()] = None,
):
# reads the "X-Token" HTTP header
return {"token": x_token}
To read a header with underscores in the name (non-standard), pass
convert_underscores=False to Header().
Rule of thumb: FastAPI converts _ → - automatically for headers; use
convert_underscores=False only for custom non-standard header names.
Use Query() with constraint kwargs inside Annotated. These map to Pydantic
field constraints under the hood.
from typing import Annotated
from fastapi import Query
@app.get("/items")
async def search(
q: Annotated[str, Query(min_length=3, max_length=100, pattern=r"^\w+$")],
page: Annotated[int, Query(ge=1, le=1000)] = 1,
):
...
Constraints are reflected in the OpenAPI schema so clients can validate before sending.
Rule of thumb: put constraint kwargs in Query()/Path()/Body() — never
validate manually in the handler body for data that comes from the request.
Use Body() annotation. Without it, FastAPI treats simple types as query params.
from typing import Annotated
from fastapi import Body
@app.post("/items/{id}/tag")
async def add_tag(
id: int,
tag: Annotated[str, Body()], # reads {"tag": "python"} or just "python" with embed=False
):
...
For a single-field body that must be wrapped in a key:
tag: Annotated[str, Body(embed=True)] # expects {"tag": "value"}
Rule of thumb: use a Pydantic model whenever you have multiple body fields;
use Body() only for a single primitive value that doesn't warrant a model.
Use Form() instead of Body(). Install python-multipart first.
from fastapi import Form
@app.post("/login")
async def login(
username: Annotated[str, Form()],
password: Annotated[str, Form()],
):
...
Note: you cannot mix JSON body and Form() fields in the same handler —
the Content-Type is either application/json or application/x-www-form-urlencoded/
multipart/form-data, not both.
Rule of thumb: use Form() for HTML form submissions or OAuth2 password flows;
use JSON body for REST API calls.
Use UploadFile for streaming (recommended) or bytes for small in-memory files.
from fastapi import UploadFile, File
@app.post("/upload")
async def upload(file: UploadFile):
content = await file.read()
return {"filename": file.filename, "size": len(content)}
UploadFile attributes: filename, content_type, file (SpooledTemporaryFile).
For multiple files: files: list[UploadFile].
Always await file.read() — the file object is async. For large files, read in
chunks to avoid loading the whole thing into memory:
while chunk := await file.read(8192):
process(chunk)
Rule of thumb: prefer UploadFile over bytes — it streams large files without
loading them into memory; bytes is fine only for small known-size uploads.
Set response_model_exclude_none=True in the route decorator. FastAPI calls
model_dump(exclude_none=True) on the Pydantic response model before serialising.
class UserOut(BaseModel):
id: int
name: str
bio: str | None = None
@app.get("/users/{id}", response_model=UserOut, response_model_exclude_none=True)
async def get_user(id: int):
return {"id": 1, "name": "Alice"}
# bio is None → omitted from response: {"id": 1, "name": "Alice"}
Related flags: response_model_exclude_unset=True (skip fields not explicitly set
by the handler) and response_model_exclude={"internal_field"} (always strip specific fields).
Rule of thumb: use exclude_none=True for sparse models where None means
"not present"; use exclude_unset=True when you want PATCH-style partial output.
Use Union[ModelA, ModelB] (or ModelA | ModelB) as the response_model.
FastAPI includes both schemas in the OpenAPI spec.
from typing import Union
class Cat(BaseModel): name: str; meows: bool
class Dog(BaseModel): name: str; barks: bool
@app.get("/pet/{id}", response_model=Union[Cat, Dog])
async def get_pet(id: int) -> Cat | Dog:
pet = await db.get_pet(id)
return pet # FastAPI picks the matching model for serialisation
FastAPI serialises using the first matching model from left to right in the
Union. For discriminated unions, use Pydantic's discriminator field for
unambiguous selection.
Rule of thumb: discriminated unions (with a type: Literal["cat"] field) are
cleaner than bare Union — they document intent and avoid ambiguous serialisation.
Use Generic[T] from typing with a Pydantic BaseModel:
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
size: int
class UserOut(BaseModel):
id: int
name: str
@app.get("/users", response_model=Page[UserOut])
async def list_users(page: int = 1, size: int = 20):
users, total = await db.paginate_users(page, size)
return Page(items=users, total=total, page=page, size=size)
Pydantic v2 fully supports generic models and FastAPI generates the correct OpenAPI schema for each concrete instantiation.
Rule of thumb: define one Page[T] generic model and reuse it across all list
endpoints — it keeps pagination schemas consistent and avoids duplication.
More Fundamentals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.