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() and File() pick other sources (see Query Parameters).

  • Conversion — the incoming text is turned into int, bool, date, UUID and so on.

  • Validation — values that cannot be converted, or violate a constraint, are rejected with a 422 response 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])

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 like response_model=), spell the union with typing.Union: Union[str, None].

  • Avoid Optional[X]: it is equivalent to Union[X, None], but the word optional suggests that the parameter is optional, whereas it only says that the value may be None. A parameter declared name: 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

q: str

yes

no

q: str | None

yes

yes

q: str = "x"

no

no

q: str | None = None

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"])

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

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}"

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.

  • 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; with Annotated it 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())

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"])

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 declares q: Optional[str] = Query(None, max_length=50). Today’s style is q: 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= on Query. The constraint is now pattern=; see Query Parameters.

  • typing.List, Dict, Union imports. Built-in generics (list[int], dict[str, int]) and | replace them.

  • type aliases. PEP 695 alias support was added in 0.128.2.