Skip to main content
友田 陽大
Python backend
Python
FastAPI
バリデーション
型安全
Pydantic
セキュリティ

FastAPI Input Validation Practical Guide: Type-Safe Query/Path/Body/Form with Annotated, Killing External Input at the Boundary

A guide to implementing the declaration and validation of query/path/body/form type-safely in FastAPI. Faithful to the latest official version: Annotated × Query/Path/Body constraints (min_length, pattern, ge/le, gt/lt), multiple values, alias, deprecated, query-parameter models and extra=forbid, Body(embed) and Field, special types like UUID/datetime, formatting 422, and testing boundary validation—all in real code.

Published
Reading time
21 min read
Author
友田 陽大
Share
Contents

"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" to Annotated[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 the int 42, and "2026-06-26" into a date.
  • Document: the constraints and metadata are reflected directly into OpenAPI (/docs).
Kind of parameterWhere the value comes fromFastAPI's toolExample
Path parameterPart of the URL pathPathitem_id in /items/{item_id}
Query parameter?key=valueQuery/items/?q=foo&limit=10
BodyThe request body (JSON)A Pydantic model / BodyA PUT's JSON payload
Formapplication/x-www-form-urlencodedFormA 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 inside Query(...), and the default value on the function argument's = None side. Don't write the default twice in Query(default=...) and the function argument. The official docs also state clearly that "when using Annotated, don't pass default to Query, 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) on limit, a malicious (or careless) ?limit=999999999 hits the DB. Almost always attach constraints to paging's limit/offset and 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 a list type, without explicit Query(), FastAPI might interpret it as the body. Remember a multi-value query as the set Annotated[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.

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: forbid is 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=True pays off for future extension: even if it's a single model now, if there's a possibility of adding user later to make it a multi-body, keying it with embed=True from 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.

TypeRequest/response formUse
UUIDA string (standard UUID format)Resource ID
datetime.datetimeISO 8601 (e.g. 2008-09-15T15:53:00+05:00)Datetime
datetime.dateISO 8601 (e.g. 2008-09-15)Date
datetime.timeISO 8601 (e.g. 14:23:55.003)Time
datetime.timedeltaA float (total seconds)Duration
frozensetA list (duplicates removed, uniqueItems)A unique set
bytesA binary-format stringBinary
DecimalA floatNumbers 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 Decimal and you protect amounts from float rounding error. Use datetime and you no longer need to strptime "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 return input as-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_validator is 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: Annotated is the current correct form. Write constraints inside Query(...) etc., and the default value on the function-argument side (don't write it twice). With Annotated, the argument-order trick (*) is unnecessary.
  • Query strings: min_length / max_length / pattern. Required omits the default, optional is = None, multiple values are Annotated[list[str], Query()].
  • Numbers and paths: ge/gt/le/lt (same for int and float). Always bind limit'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+). With model_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 is Body(embed=True), and in-model validation is Field (import from pydantic).
  • Special types: UUID/datetime/date/time/timedelta/Decimal/frozenset/Enum are validated and converted with a single type. Don't hand-write strptime.
  • Application: fold frequently-used constraints into an Annotated type alias and reuse them (DRY). Tighten the output with response_model. Format the 422 in an exception handler (don't over-emit input).
  • 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)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading