Python Types & Annotated
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. All code runs on Python 3.10 or newer (the type statement example needs 3.12) and was executed on
Python 3.13. It covers only the Python typing features that FastAPI uses; general typing (generics, protocols,
static checkers) is explained in the Python Reference, in
Type Hints.
Type Hints Drive Everything
In most web frameworks, the types of a handler’s parameters are comments. In FastAPI they are the specification. From one declaration per parameter FastAPI derives, at once:
-
Where the value comes from — a name that appears in the path template is a path parameter; a parameter of a simple type is a query parameter; a Pydantic model is the request body; markers such as
Header(),Cookie(),Form()andFile()pick other sources (see Query Parameters). -
Conversion — the incoming text is turned into
int,bool,date,UUIDand so on. -
Validation — values that cannot be converted, or violate a constraint, are rejected with a
422response that explains the problem. -
Documentation — the same information becomes the OpenAPI schema and the interactive documentation.
-
Editor support — the function body sees proper types, so completion, refactoring and static checking work.
from datetime import date
from typing import Annotated
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/books/{book_id}")
def read_book(
book_id: int, # path parameter, converted to int
published_after: date | None = None, # optional query parameter, parsed as a date
q: Annotated[str | None, Query(max_length=20)] = None, # validated + documented
) -> dict:
return {"book_id": book_id, "published_after": published_after, "q": q}
c = TestClient(app)
print(c.get("/books/3?published_after=2024-05-01").json())
print(c.get("/books/x").json()["detail"][0]["msg"])
print(c.get("/books/3?published_after=yesterday").json()["detail"][0]["msg"])
print(c.get("/books/3?q=" + "a" * 21).json()["detail"][0]["type"])
params = c.get("/openapi.json").json()["paths"]["/books/{book_id}"]["get"]["parameters"]
print([(p["name"], p["in"], p["required"]) for p in params])
Source: FastAPI: Python Types Intro
The five printed lines show every effect: published_after was parsed into a date (the response renders it
back as '2024-05-01'), 'x' and 'yesterday' are rejected with Pydantic’s messages, a 21-character q fails
with string_too_long, and /openapi.json lists book_id as a required path parameter and the other two as
optional query parameters.
X | None vs Optional
A union of types is written A | B (PEP 604, Python 3.10+). "This value may be absent" is str | None. The official
guidance in Advanced Python Types is:
-
Prefer the
|syntax everywhere a type annotation is allowed. This section’s code does so throughout. -
If you cannot use
|(the docs name places likeresponse_model=), spell the union withtyping.Union:Union[str, None]. -
Avoid
Optional[X]: it is equivalent toUnion[X, None], but the word optional suggests that the parameter is optional, whereas it only says that the value may beNone. A parameter declaredname: Optional[str]with no default is still required.
That last point matters in FastAPI because "required" and "nullable" are independent:
| Declaration | Required in the request | None accepted |
|---|---|---|
|
yes |
no |
|
yes |
yes |
|
no |
no |
|
no |
yes |
Whether a parameter is required is decided by the default value, and whether it may be None by the type.
from typing import Annotated
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/a")
def a(q: str | None): ... # required, but may be None? -> see below
@app.get("/b")
def b(q: str | None = None): ... # optional
@app.get("/c")
def c_(q: Annotated[str | None, Query()] = None): ...
cl = TestClient(app)
print(cl.get("/a").status_code, cl.get("/b").status_code)
spec = cl.get("/openapi.json").json()
print(spec["paths"]["/a"]["get"]["parameters"][0]["required"], spec["paths"]["/a"]["get"]["parameters"][0]["schema"])
Source: FastAPI: Advanced Python Types
The first route (q: str | None, no default) answers 422 when q is missing yet declares q as required: true
with a schema anyOf string and null; the second answers 200.
from typing import Optional, Union
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Book(BaseModel):
title: str
class Magazine(BaseModel):
issue: int
app = FastAPI()
@app.get("/item", response_model=Union[Book, Magazine, None])
def item(): return {"title": "Dune"}
@app.get("/item2", response_model=Book | Magazine | None)
def item2(): return {"issue": 3}
@app.get("/item3")
def item3(x: Optional[str] = None): return {"x": x}
c = TestClient(app); print(c.get("/item").json(), c.get("/item2").json(), c.get("/item3?x=1").json())
Source: FastAPI: Advanced Python Types
In FastAPI 0.142 on Python 3.13, response_model=Union[Book, Magazine, None] and response_model=Book | Magazine | None
both worked, and so did Optional[str] for a parameter. Still follow the documented guidance: it is the style the official examples use, and a uniform style helps readers.
Annotated (PEP 593)
typing.Annotated[T, metadata…] attaches extra metadata to a type. Python and type checkers see only T; the
metadata is for libraries. The first argument is the type, everything after it is metadata.
from typing import Annotated
def say_hello(name: Annotated[str, "this is just metadata"]) -> str:
return f"Hello {name}"
Source: FastAPI: Python Types Intro
FastAPI reads its own markers out of that metadata: Query(), Path(), Header(), Cookie(), Body(), Form(),
File(), Depends() and Security() all go inside Annotated:
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/books")
async def search_books(q: Annotated[str | None, Query(max_length=50)] = None):
return {"q": q}
FastAPI added Annotated support, and started recommending it, in 0.95.0 (2023-03-18); the documentation says to use at
least 0.95.1. The older style puts the marker in the default value (q: str | None = Query(default=None, max_length=50)).
Both styles still work, but the official tutorial prefers Annotated, and this section uses it everywhere.
Why Annotated is recommended
-
The default is the real default. With
Annotated,= "rick"is the actual Python default. With the older style the "default" is a marker object that FastAPI knows how to interpret. -
The function keeps working as plain Python. You can call it from other code, a test or a script without FastAPI.
-
No parameter-ordering tricks. Python requires parameters without defaults before parameters with defaults, which forced tricks such as a leading
*to reorder arguments in the old style; withAnnotatedit is not needed. -
Several metadata items can be combined (a validation marker plus another library’s marker), and the same function can be used with other tools that read
Annotated(the docs mention Typer). -
Reuse. An annotated type is a value that can be stored in a variable (below).
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/new")
def new_style(q: Annotated[str, Query(max_length=50)] = "rick") -> dict:
return {"q": q}
@app.get("/old")
def old_style(q: str = Query(default="rick", max_length=50)) -> dict:
return {"q": q}
# Called as plain Python functions, outside FastAPI:
print(new_style()) # {'q': 'rick'} -- the default is the real default
print(old_style()) # {'q': Query(rick)} -- the default is a FastAPI marker object
The first call returns the plain default. The second returns a Query(rick) marker object instead of a string, which
is exactly the surprise that Annotated removes.
Pitfall: with Annotated, the default belongs after the = sign, not inside the marker.
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
try:
@app.get("/x")
def x(q: Annotated[int, Query(default=5)]): ...
except Exception as e:
print(type(e).__name__, str(e)[:120])
FastAPI rejects Query(default=5) inside Annotated with an AssertionError telling you to set the default with
= instead.
Reusable Annotated Aliases
Because Annotated[…] is an ordinary expression, you can give it a name and use it as a type in many places. That
removes duplicated constraints and keeps documentation consistent:
from typing import Annotated
from fastapi import FastAPI, Path, Query
from fastapi.testclient import TestClient
BookId = Annotated[int, Path(ge=1, description="Primary key of a book")]
PageSize = Annotated[int, Query(ge=1, le=100, description="Items per page")]
app = FastAPI()
@app.get("/books/{book_id}")
def read_book(book_id: BookId) -> dict:
return {"book_id": book_id}
@app.get("/books")
def list_books(limit: PageSize = 20) -> dict:
return {"limit": limit}
@app.get("/authors/{author_id}/books")
def author_books(author_id: BookId, limit: PageSize = 20) -> dict:
return {"author_id": author_id, "limit": limit}
c = TestClient(app)
print(c.get("/books/0").json()["detail"][0]["type"], c.get("/books?limit=500").json()["detail"][0]["type"])
print(c.get("/authors/2/books?limit=5").json())
BookId and PageSize carry their constraints and OpenAPI descriptions everywhere they are used; the invalid values
produce greater_than_equal and less_than_equal errors. The same technique works for dependencies, which is how
FastAPI recommends sharing them:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
def pagination(offset: int = 0, limit: int = 20) -> dict[str, int]:
return {"offset": offset, "limit": limit}
Pagination = Annotated[dict[str, int], Depends(pagination)] # alias = type + metadata
@app.get("/books")
def list_books(page: Pagination) -> dict:
return page
@app.get("/authors")
def list_authors(page: Pagination) -> dict:
return page
print(TestClient(app).get("/authors?limit=5").json())
Source: FastAPI: Dependencies
Pagination is a type (dict[str, int]) plus the instruction to obtain it via Depends(pagination). See
Dependency Injection for the full picture, and
Path Parameters for Path constraints.
Conventions that keep aliases readable: name them after the concept (BookId, CurrentUser, DbSession), keep
them in one module (for example app/types.py in the final project layout), and put the alias in the signature
rather than re-declaring the metadata inline.
PEP 695 type Aliases
On Python 3.12 and later the type statement (PEP 695) creates an alias object that type checkers treat as a
first-class alias. FastAPI supports these TypeAliasType aliases since 0.128.2 (2026-02-05):
from typing import Annotated
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
type PageSize = Annotated[int, Query(ge=1, le=100)] # PEP 695, Python 3.12+
app = FastAPI()
@app.get("/books")
def list_books(limit: PageSize = 20) -> dict:
return {"limit": limit}
c = TestClient(app)
print(c.get("/books?limit=5").json(), c.get("/books?limit=500").status_code)
print(c.get("/openapi.json").json()["paths"]["/books"]["get"]["parameters"][0]["schema"])
Source: FastAPI: Release Notes
The constraints in the alias reach the OpenAPI schema (minimum: 1, maximum: 100, default: 20) and the out-of-range
request gets a 422. The plain-assignment alias used on this page (PageSize = Annotated[…]) works on every
supported Python version, 3.10 and later, so use it unless you target 3.12+ and want the type form. Use only one style per
code base. Note that the type statement is a syntax error on Python 3.10 and 3.11.
Where Older Guides Are Outdated, and What Changed Recently
-
Optional[X]and default-value markers. Material from 2021-2022 declaresq: Optional[str] = Query(None, max_length=50). Today’s style isq: Annotated[str | None, Query(max_length=50)] = None(0.95.0 and later; the|operator needs Python 3.10, which is the minimum for FastAPI 0.142 since support for 3.8 and 3.9 was dropped in 0.125.0 and 0.129.0). -
regex=onQuery. The constraint is nowpattern=; see Query Parameters. -
typing.List,Dict,Unionimports. Built-in generics (list[int],dict[str, int]) and|replace them. -
typealiases. PEP 695 alias support was added in 0.128.2.