"Just receive it for now, and reject it later with an if"—have you ever put off an API's input validation that way? item_id came as a negative number, limit got 99999 and the DB returned all rows, a query had an unfamiliar parameter ?admin=true mixed in—it's too late to notice all of these "after the value reaches the handler." Values coming from outside, kill at the boundary. This is the first principle of security, and the area FastAPI is best at.
This article is a guide to declaring and validating query, path, body, and form parameters type-safely in FastAPI. While following the relevant chapter of FastAPI's official tutorial faithfully to the latest spec, it digs into the areas the official docs—being educational—don't touch: reusable Annotated type aliases, tightening the contract with a query-parameter model, formatting the 422 body, and testing boundary values. As source material, I'll weave in decisions from the in-house AI platform I built for a major Japanese broadcaster (bundling multiple FastAPI services in a monorepo and validating externally-brought-in material zero-trust; designing from the start an entrance that sorts into clean/quarantine via malware scanning).
The rules of this article: APIs and notation are based on the FastAPI official documentation (as of June 2026). In recent years FastAPI officially switched its recommendation for declaring validation metadata from "the old style of placing
Query()in the default value" toAnnotated[T, Query(...)], and introduced query-parameter models (0.115.0+). This article conforms to this latest version. Specs are revised, so always confirm the latest behavior in the official docs before going to production. Don't write secrets in code (this article's samples have no hardcoding either).
0. First, the principle: don't trust external input (validate, convert, document with a single type)
Before implementing, fix the design philosophy in one line.
All external input is validated, converted, and rejected at the boundary (the route handler). Only "validated, typed values" reach the handler body.
FastAPI realizes this principle with Python's type hints themselves. Write item_id: int, and a request that comes as a string is converted to an integer, and if it can't be converted, it's automatically rejected with 422. Add the constraint Annotated[int, Path(ge=1)] there, and the contract "an integer ≥ 1" is engraved in the type. What's working here is doing three jobs in one place.
- Validate: reject values that don't satisfy the constraints.
- Convert: turn the string
"42"into theint42, and"2026-06-26"into adate. - Document: the constraints and metadata are reflected directly into OpenAPI (
/docs).
| Kind of parameter | Where the value comes from | FastAPI's tool | Example |
|---|---|---|---|
| Path parameter | Part of the URL path | Path | item_id in /items/{item_id} |
| Query parameter | ?key=value | Query | /items/?q=foo&limit=10 |
| Body | The request body (JSON) | A Pydantic model / Body | A PUT's JSON payload |
| Form | application/x-www-form-urlencoded | Form | A login form, etc. |
And invalid input is returned, without exception, as 422 Unprocessable Entity. If the type doesn't match the declaration, the handler doesn't execute a single line—this is the true nature of "kill at the boundary." This article works through, in order, how to declare these 4 kinds precisely with a single Annotated.
Why validation is security: unvalidated input is the entrance for SQL injection, path traversal, resource exhaustion (a huge
limit), and enumeration attacks. Crush "input crushable by type" with the type, and the downstream business logic stands on a safe premise. It's the same layer of discussion as guarding "who, what's allowed" with authentication and authorization (FastAPI auth/authorization here, boundary validation with Pydantic v2 here).
1. Query parameters and string validation: Annotated × Query
The most frequent are query parameters. Let me look at validating ?q=...&limit=....
1.1 First, the bare declaration
Just by writing a type hint on a function argument, FastAPI treats it as a query parameter.
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/")
async def read_items(q: str | None = None):
# q はクエリパラメータ。デフォルトが None なので「任意」。
# ?q=foo で渡せば文字列、無ければ None。
results = {"items": [{"item_id": "Foo"}]}
if q:
results.update({"q": q})
return results
Conversion works even with just this. But it can't bind "length" or "format." Here we add Query.
1.2 Declare constraints with Query (Annotated is the current correct form)
What the official docs currently recommend is not the old style of placing Query() in the default value, but Annotated[T, Query(...)].
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items/")
async def read_items(
q: Annotated[str | None, Query(min_length=3, max_length=50)] = None,
):
# 3〜50文字の文字列のみ受理。範囲外は 422 で自動的に弾かれる。
# デフォルト値(= None)は関数引数側に置く。Query(default=...) は使わない。
results = {"items": [{"item_id": "Foo"}]}
if q:
results.update({"q": q})
return results
There are 3 constraints that work on strings.
min_length/max_length: the lower/upper bound of the character count.pattern: a regular expression.
# pattern で「許される形」を正規表現で固定する(^...$ で全体一致を強制)。
q: Annotated[str | None, Query(min_length=3, max_length=50, pattern="^fixedquery$")] = None
The iron rule when using
Annotated: write constraints insideQuery(...), and the default value on the function argument's= Noneside. Don't write the default twice inQuery(default=...)and the function argument. The official docs also state clearly that "when usingAnnotated, don't passdefaulttoQuery, and use the function argument's default." This is the "single source of truth."
1.3 Distinguish required, optional, and "optional but None-allowed" with the type
This is easy to confuse, so fix it into 3 patterns. The presence or absence of a default value decides required/optional.
# (1) 任意(省略可、既定 None):来れば検証、無ければ None
q: Annotated[str | None, Query(min_length=3)] = None
# (2) 必須(省略不可):デフォルトを書かない。省略すると 422
q: Annotated[str, Query(min_length=3)]
# (3) 必須だが None も明示で許す:デフォルトを書かない(=必須)が、型は None も許容
q: Annotated[str | None, Query(min_length=3)]
The key is one line—write a default value and it's "optional," don't write one and it's "required." | None only expresses "whether the value None is allowed," and is independent of required/optional.
# 必須クエリ:デフォルト値を書かない。省略すると 422。
@app.get("/items/")
async def read_items(q: Annotated[str, Query(min_length=3)]):
return {"q": q}
Being able to express "whether it's required" with only the type and the presence of a default is FastAPI's beauty. You don't need hand-written validation like if q is None: raise ... (that branch becomes unreachable in the first place).
2. Constraints on numbers and path parameters: ge / gt / le / lt
2.1 Path parameters and Path
A value embedded in the URL path is a path parameter, validated with Path. Path parameters are part of the URL so they're always required (omission breaks the path).
from typing import Annotated
from fastapi import FastAPI, Path, Query
app = FastAPI()
@app.get("/items/{item_id}")
async def read_items(
item_id: Annotated[int, Path(title="取得するアイテムのID", ge=1)],
q: Annotated[str | None, Query(alias="item-query")] = None,
):
# item_id は 1 以上の整数のみ。0 や負数、文字列は 422。
results = {"item_id": item_id}
if q:
results.update({"q": q})
return results
2.2 There are 4 numeric constraints (same for int and float)
What binds a number's range is the 4 the initials indicate.
ge: greater than or equal (≧)gt: greater than (>)le: less than or equal (≦)lt: less than (<)
# 1 ≦ item_id ≦ 1000 の整数。さらに 0 < size < 10.5 の float。
@app.get("/items/{item_id}")
async def read_items(
item_id: Annotated[int, Path(title="アイテムID", ge=1, le=1000)],
size: Annotated[float, Query(gt=0, lt=10.5)],
):
return {"item_id": item_id, "size": size}
Numeric boundaries have many accidents: without an upper bound (
le=100) onlimit, a malicious (or careless)?limit=999999999hits the DB. Almost always attach constraints to paging'slimit/offsetand an ID's lower bound (ge=1) is the production habit. Think of boundaries as the "attack surface."
2.3 With Annotated you don't have to mind argument order
In the old style (placing Path()/Query() in default values), you were bound by Python's constraint "arguments with defaults come after those without," requiring the *, (keyword-only argument) trick. Using Annotated makes this problem disappear—it works in any order.
# q(デフォルトなし)の後に item_id を置いても、Annotated なら問題ない。
@app.get("/items/{item_id}")
async def read_items(
q: str,
item_id: Annotated[int, Path(title="アイテムID", ge=1)],
):
return {"item_id": item_id, "q": q}
The official docs also state that "the * trick is probably neither important nor necessary if you use Annotated." Unify on Annotated and one thing to remember decreases—a subtle but effective readability improvement.
3. Multiple values, aliases, deprecation: list, alias, deprecated
3.1 Receive the same key multiple times (list[str])
A query where the same key comes multiple times like ?tags=a&tags=b is received with a list type.
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items/")
async def read_items(q: Annotated[list[str] | None, Query()] = None):
# ?q=foo&q=bar → q == ["foo", "bar"]。リストには Query() が必要。
return {"q": q}
You can also make the default a "non-empty list."
# 何も来なければ ["foo", "bar"] を既定値として使う。
q: Annotated[list[str], Query()] = ["foo", "bar"]
A list needs
Query(): for alisttype, without explicitQuery(), FastAPI might interpret it as the body. Remember a multi-value query as the setAnnotated[list[str], Query()]—that's safe.
3.2 Change the name on the URL (alias)
When you want to receive in the URL a name unusable as a Python identifier like item-query (with a hyphen), use alias.
# URL は ?item-query=foo、Python 側の変数は q。
q: Annotated[str | None, Query(alias="item-query")] = None
3.3 Deprecate it / hide from the schema (deprecated / include_in_schema)
When you want to retire an API without breaking it, you can mark it "deprecated" in the docs with deprecated=True. To hide an internal parameter from OpenAPI, include_in_schema=False.
@app.get("/items/")
async def read_items(
q: Annotated[
str | None,
Query(
title="検索クエリ",
description="アイテムを検索するための文字列",
alias="item-query",
min_length=3,
max_length=50,
deprecated=True, # /docs に「非推奨」と表示(まだ動く)
include_in_schema=False, # OpenAPI スキーマから除外(docs に出さない)
),
] = None,
):
return {"q": q}
title / description are metadata for docs readability. For a public API, attaching them to the main parameters grows the spec.
4. Query-parameter models: reject unknown parameters and tighten the contract
As queries increase, limit, offset, order_by, tags… the arguments bloat. What lets you fold these into one Pydantic model is FastAPI 0.115.0+'s query-parameter model.
4.1 Gather related queries into one model
from typing import Annotated, Literal
from fastapi import FastAPI, Query
from pydantic import BaseModel, Field
app = FastAPI()
class FilterParams(BaseModel):
# Field でクエリごとの制約を宣言する。第8章で型エイリアスに畳んで再利用する。
limit: int = Field(100, gt=0, le=100) # 1〜100、既定 100
offset: int = Field(0, ge=0) # 0 以上、既定 0
order_by: Literal["created_at", "updated_at"] = "created_at" # 列挙で固定
tags: list[str] = []
@app.get("/items/")
async def read_items(filter_query: Annotated[FilterParams, Query()]):
# filter_query.limit / .offset / .order_by / .tags が検証済みで使える。
return filter_query
With this, ?limit=10&offset=20&order_by=updated_at&tags=a&tags=b reaches the handler as one type-safe object. The list of arguments disappears, and the constraints cohere in the model (SRP).
4.2 Reject unknown queries with extra: forbid (tighten the contract)
This is a move that pays off in production. By default, query parameters not in the model are silently ignored. Both ?admin=true and ?limt=10 (a typo of limit) pass through without an error—a breeding ground for accidents.
Adding model_config = {"extra": "forbid"} makes unknown queries rejected with 422.
class FilterParams(BaseModel):
model_config = {"extra": "forbid"} # 未知のクエリパラメータを禁止する
limit: int = Field(100, gt=0, le=100)
offset: int = Field(0, ge=0)
order_by: Literal["created_at", "updated_at"] = "created_at"
tags: list[str] = []
Send an extra parameter like ?tool=plumbus and it returns this.
{
"detail": [
{
"type": "extra_forbidden",
"loc": ["query", "tool"],
"msg": "Extra inputs are not permitted",
"input": "plumbus"
}
]
}
Why "rejecting" is correct: silencing unknown parameters means a client-side typo (
?limt=10) goes unnoticed by the server, and a 200 is returned even though the filter isn't working—the worst kind of silent failure.extra: forbidis an explicit refusal of "I don't accept what's not in the contract," surfacing bugs at the boundary. For both public and internal APIs, it's strongly recommended on search endpoints. On the broadcaster platform too, I was thorough about "don't silently pass unexpected things; reject them explicitly" for externally-brought-in input (the same philosophy as the entrance design that sorts into clean/quarantine).
5. Body: multiple parameters, embed, Field
A POST/PUT JSON body is basically received with a Pydantic model. Here I dig into body-specific declarations.
5.1 Mix path, query, and body
You can declare all 3 kinds at once in one handler. FastAPI judges which comes from where from the type.
from typing import Annotated
from fastapi import FastAPI, Path
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = None
price: float
tax: float | None = None
@app.put("/items/{item_id}")
async def update_item(
item_id: Annotated[int, Path(title="アイテムID", ge=0, le=1000)], # パス
q: str | None = None, # クエリ(単純型)
item: Item | None = None, # ボディ(モデル)
):
results = {"item_id": item_id}
if q:
results.update({"q": q})
if item:
results.update({"item": item})
return results
The judgment rule is simple—a Pydantic model is the body, a simple type (str/int, etc.) is a query, and if there's a name in the path, it's a path.
5.2 Multiple body parameters
Take multiple models in the body, and FastAPI expects JSON keyed by the argument names.
class User(BaseModel):
username: str
full_name: str | None = None
@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item, user: User):
return {"item_id": item_id, "item": item, "user": user}
The expected body has the argument names item / user as top-level keys.
{
"item": {
"name": "Foo",
"description": "The pretender",
"price": 42.0,
"tax": 3.2
},
"user": {
"username": "dave",
"full_name": "Dave Grohl"
}
}
5.3 Include a single value in the body (Body)
When you want to put a simple type like int in the body, not the query, make Body() explicit. Without it, FastAPI interprets it as a query.
from fastapi import Body
@app.put("/items/{item_id}")
async def update_item(
item_id: int,
item: Item,
user: User,
importance: Annotated[int, Body(gt=0)], # クエリではなくボディの値として、かつ > 0 を強制
):
return {"item_id": item_id, "item": item, "user": user, "importance": importance}
Body() can also have constraints like gt/ge/lt/le, just like Query/Path.
5.4 Wrap a single model with a key (Body(embed=True))
When the body is only one model, FastAPI expects the model's contents flat (as-is) by default. Attach embed=True and it expects a wrapped form keyed by the argument name.
@app.put("/items/{item_id}")
async def update_item(
item_id: int,
item: Annotated[Item, Body(embed=True)], # item を "item" キーで包む
):
return {"item_id": item_id, "item": item}
{
"item": {
"name": "Foo",
"description": "The pretender",
"price": 42.0,
"tax": 3.2
}
}
embed=Truepays off for future extension: even if it's a single model now, if there's a possibility of addinguserlater to make it a multi-body, keying it withembed=Truefrom the start makes the body shape consistent and avoids a breaking change for the client (ETC). Whether to choose "flat because it's single" or "wrap looking ahead" depends on the API's stability policy.
5.5 In-model validation is Field (import from Pydantic)
What attaches constraints and metadata to each attribute of a model is Field. Import Field from pydantic, not fastapi.
from typing import Annotated
from fastapi import Body, FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class Item(BaseModel):
name: str
description: str | None = Field(
default=None, title="アイテムの説明", max_length=300 # 最大300文字
)
price: float = Field(gt=0, description="価格はゼロより大きいこと") # > 0 を強制
tax: float | None = None
@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Annotated[Item, Body(embed=True)]):
return {"item_id": item_id, "item": item}
Field has the same mechanism and same constraints as Query/Path/Body (internally the same FieldInfo). That is, remember "parameter validation is Query/Path/Body, in-model validation is Field," and the constraint vocabulary (gt/max_length/title/description…) is all common. Being able to place validation logic consistently at the boundary (route) and in the model (schema) is the strength of FastAPI + Pydantic (Pydantic v2's validation design here).
6. Special types: UUID, datetime, Decimal, Enum, frozenset
Besides the basic types (int/float/str/bool), FastAPI validates and converts many standard types with a single type. You don't need to hand-write string parsing.
| Type | Request/response form | Use |
|---|---|---|
UUID | A string (standard UUID format) | Resource ID |
datetime.datetime | ISO 8601 (e.g. 2008-09-15T15:53:00+05:00) | Datetime |
datetime.date | ISO 8601 (e.g. 2008-09-15) | Date |
datetime.time | ISO 8601 (e.g. 14:23:55.003) | Time |
datetime.timedelta | A float (total seconds) | Duration |
frozenset | A list (duplicates removed, uniqueItems) | A unique set |
bytes | A binary-format string | Binary |
Decimal | A float | Numbers needing precision (money, etc.) |
from datetime import datetime, time, timedelta
from typing import Annotated
from uuid import UUID
from fastapi import Body, FastAPI
app = FastAPI()
@app.put("/items/{item_id}")
async def read_items(
item_id: UUID, # パスを UUID として検証・変換
start_datetime: Annotated[datetime, Body()], # ISO 8601 → datetime
end_datetime: Annotated[datetime, Body()],
process_after: Annotated[timedelta, Body()], # 秒数 → timedelta
repeat_at: Annotated[time | None, Body()] = None,
):
# ここに来た時点で全部「検証済みの Python オブジェクト」。日時の演算がそのまま書ける。
start_process = start_datetime + process_after
duration = end_datetime - start_process
return {
"item_id": item_id,
"start_process": start_process,
"duration": duration,
}
6.1 Make "allowed values" a type with Enum
A closed set like "the status is one of draft/published/archived" is best expressed with Enum (or Literal). Out-of-range is automatically 422.
from enum import Enum
from fastapi import FastAPI
app = FastAPI()
class Status(str, Enum):
draft = "draft"
published = "published"
archived = "archived"
@app.get("/items/")
async def read_items(status: Status = Status.draft):
# ?status=foo は 422。?status=published は Status.published に変換される。
return {"status": status}
Special types handle "conversion" too: use
Decimaland you protect amounts fromfloatrounding error. Usedatetimeand you no longer need tostrptime"2026-06-26T..."yourself. Just by choosing the type correctly, parsing, validation, documentation, and the OpenAPI schema all come together—this is the return of "a single type."
7. Application: reusable Annotated type aliases, paging, formatting 422
From here is beyond the official tutorial. The practice of "not scattering, reusing, and returning cleanly" for validation.
7.1 Fold frequently-used constrained parameters into a type alias (DRY)
Writing Annotated[int, Query(gt=0, le=100)] every time for repeatedly-appearing parameters like limit, offset, and a search term is a DRY violation. Fold the Annotated type into a named alias and reuse it across all endpoints.
from typing import Annotated
from fastapi import Query
# ページングと検索語の「契約」を1箇所に定義する(単一の真実源)。
Offset = Annotated[int, Query(ge=0, description="先頭から読み飛ばす件数")]
Limit = Annotated[int, Query(gt=0, le=100, description="取得する最大件数(上限100)")]
SearchQuery = Annotated[str | None, Query(min_length=2, max_length=50)]
@app.get("/items/")
async def list_items(q: SearchQuery = None, offset: Offset = 0, limit: Limit = 100):
# 制約はエイリアスに集約。各ハンドラは名前を書くだけ。上限変更も1箇所で済む。
return {"q": q, "offset": offset, "limit": limit}
@app.get("/users/")
async def list_users(offset: Offset = 0, limit: Limit = 100):
# 同じページング契約を、users でもそのまま再利用する。
return {"offset": offset, "limit": limit}
"I want to change limit's upper bound from 100→200" propagates to all endpoints with a single-place fix. This is the same pattern as folding CurrentUser = Annotated[User, Depends(...)] in the auth guide (→ the auth Annotated type alias). In a large app, consolidate these shared types in app/api/deps.py, etc. (project structure here).
7.2 Fix the output with a type too (response_model)
Once you've killed input at the boundary, tighten the output with a contract too. Specify response_model, and the handler's return value is shaped and validated to that schema, and fields not in the declaration are excluded from the response—you can prevent the leakage of internal fields or PII with the type.
from pydantic import BaseModel
class ItemPublic(BaseModel):
name: str
price: float
# internal_cost はあえて含めない → レスポンスに絶対出ない(型による情報遮断)
@app.get("/items/{item_id}", response_model=ItemPublic)
async def read_item(item_id: int) -> ItemPublic:
# DB から内部フィールド付きで取得しても、ItemPublic に無いものは返らない。
...
Tighten both input (Query/Path/Body/Field) and output (response_model) with types—this makes "the shape received" and "the shape returned" explicit as the API's contract.
7.3 Format the 422 body (a client-friendly error)
FastAPI's default 422 is array-shaped (detail: [...]), which is sufficient mechanically, but you sometimes want to unify it into a form easy to handle on the front. Swap the RequestValidationError handler.
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
# 検証エラーを自社フロントの規約に合わせた一定の形へ整える。
# exc.errors() に loc/msg/type が入る。生メッセージは出し過ぎない(情報漏洩を避ける)。
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "validation_error",
"fields": [
{"location": ".".join(str(p) for p in e["loc"]), "message": e["msg"]}
for e in exc.errors()
],
},
)
Don't put internal info in the error body:
exc.errors()can include the input value itself (input). On endpoints where secret values mix in, don't returninputas-is. For the overall error design (centralizing exception handlers, logging, correlation IDs), follow the production-operations guide. Validation's job is not just to "reject" but to "safely convey what's wrong."
8. Testing: always verify boundary values and 422
There's no production launch without a verification path. For input validation, always test boundary values and the abnormal path (422). Don't write this and you miss the accident "I thought I attached a constraint but it isn't working."
# tests/test_validation.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
# 1) パスの数値制約:ge=1。0 や負数・文字列は 422、境界の 1 は 200。
def test_item_id_lower_bound():
assert client.get("/items/1").status_code == 200 # 境界(OK)
assert client.get("/items/0").status_code == 422 # 下限割れ
assert client.get("/items/-3").status_code == 422
assert client.get("/items/abc").status_code == 422 # 型変換失敗
# 2) クエリの文字列長:min_length=3。2文字は 422、3文字は 200。
def test_query_min_length():
assert client.get("/items/?q=ab").status_code == 422 # 短すぎ
assert client.get("/items/?q=abc").status_code == 200 # 境界(OK)
# 3) limit の上限:le=100。101 は 422、100 は 200。
def test_limit_upper_bound():
assert client.get("/list/?limit=100").status_code == 200
assert client.get("/list/?limit=101").status_code == 422
# 4) クエリモデルの extra=forbid:未知パラメータは 422 で拒否される。
def test_unknown_query_is_rejected():
res = client.get("/items/?limit=10&unknown=x")
assert res.status_code == 422
assert res.json()["detail"][0]["type"] == "extra_forbidden"
# 5) 正常系:境界の内側はちゃんと通り、変換された値が返る。
def test_happy_path():
res = client.get("/items/?q=hello&limit=10")
assert res.status_code == 200
The testing tip is "both sides of the boundary." For ge=1, 0 (fails) and 1 (passes); for le=100, 100 (passes) and 101 (fails)—by poking the exact edge of the constraint, you catch typical bugs like mixing up < and <=. Also explicitly test that "input that should be rejected" like extra: forbid is rejected (proof that it isn't silenced).
Two-tier validation: route-handler-level validation (this article) confirms 422 with Playwright/TestClient, and pure in-model logic like
Field/field_validatoris verified with vitest-style unit tests of the Pydantic model alone—the principle is test validation logic at the layer where you placed it (Pydantic standalone validation tests).
9. Summary: a FastAPI input-validation cheat sheet
A quick reference for when you're unsure.
- The principle: kill external input at the boundary. FastAPI does validation, conversion, and documentation at once with a type hint +
Annotated[T, Query/Path/Body(...)]. Invalid input is automatically 422. - Notation:
Annotatedis the current correct form. Write constraints insideQuery(...)etc., and the default value on the function-argument side (don't write it twice). WithAnnotated, the argument-order trick (*) is unnecessary. - Query strings:
min_length/max_length/pattern. Required omits the default, optional is= None, multiple values areAnnotated[list[str], Query()]. - Numbers and paths:
ge/gt/le/lt(same for int and float). Always bindlimit's upper bound and an ID's lower bound (the attack surface). - Aliases and retirement:
alias(the name on the URL),deprecated(shows "deprecated" in docs),include_in_schema=False(excludes from the schema). - Query models: fold related queries into a Pydantic model (
Annotated[FilterParams, Query()], 0.115.0+). Withmodel_config = {"extra": "forbid"}, reject unknown parameters and typos with 422 and tighten the contract. - Body: receive with a Pydantic model. Multiple bodies are keyed by argument name, a single value is
Body(), wrapping a single model isBody(embed=True), and in-model validation isField(import frompydantic). - Special types:
UUID/datetime/date/time/timedelta/Decimal/frozenset/Enumare validated and converted with a single type. Don't hand-writestrptime. - Application: fold frequently-used constraints into an
Annotatedtype alias and reuse them (DRY). Tighten the output withresponse_model. Format the 422 in an exception handler (don't over-emitinput). - Testing: always verify both sides of the boundary (0/1, 100/101) and 422 (including
extra_forbidden).
FastAPI is a "validation comes just by writing type hints" framework, but production quality is decided by boundary design. Engrave the input shape in the type, bind the range with constraints, reject unknown parameters, tighten the output with a contract too, and test boundary values—none of it is flashy, but this accumulation creates "an API where dirty values never reach the business logic." Validation is the most cost-effective investment, standing all the downstream code on a safe premise.
On the in-house AI platform for a broadcaster, I built in from the start an entrance that bundles multiple FastAPI services in a monorepo and validates externally-brought-in material zero-trust (a design that sorts into clean/quarantine). With generative AI (Claude Code) as my partner, my approach is to build fast and cheaply, solo while guaranteeing quality with boundary validation and tests.
"This FastAPI API's input validation is scattered across hand-written ifs," "how should I tighten the query contract and design the 422?"—I'll accompany you end-to-end, from that decision through implementation, testing, and operation. Feel free to reach out, even from the requirements-organizing stage.
References (official documentation)
- Query Parameters and String Validations (FastAPI) —
Annotated×Query,min_length/max_length/pattern,alias/deprecated/include_in_schema, multiple values - Path Parameters and Numeric Validations (FastAPI) —
Path,ge/gt/le/lt, argument order andAnnotated - Query Parameter Models (FastAPI) — receiving queries with a Pydantic model,
model_config={"extra": "forbid"} - Body - Multiple Parameters (FastAPI) — multiple bodies,
Body(),Body(embed=True), mixing path/query/body - Body - Fields (FastAPI) — validating in-model with
Field(import frompydantic) - Extra Data Types (FastAPI) — special types like
UUID/datetime/Decimal/frozenset - Pydantic data types — the list of data types Pydantic supports