# 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: 2026-06-26
- Author: 友田 陽大
- Tags: Python, FastAPI, バリデーション, 型安全, Pydantic, セキュリティ
- URL: https://tomodahinata.com/en/blog/fastapi-request-validation-query-path-body-parameters-guide
- Category: Python backend
- Pillar guide: https://tomodahinata.com/en/blog/fastapi-production-async-pydantic-observability-guide

## Key points

- Don't trust external input. FastAPI does validation, conversion, and documentation at once with the single 'type hint + Annotated[T, Query/Path/Body(...)]'. Invalid input is automatically rejected with 422, and dirty values don't reach the handler
- Annotated is the current correct form. Declare constraints as Query/Path arguments: for strings min_length/max_length/pattern, for numbers ge/gt/le/lt. Required omits the default, optional gives None, multiple values are list[str]
- Fold related queries into a Pydantic model (Annotated[FilterParams, Query()], 0.115.0+). With model_config={'extra':'forbid'}, reject typos and unknown parameters with 422 and tighten the API contract
- Design the body with multiple parameters, Body(embed=True), and Field. Field has the same constraints as Query/Path/Body and handles in-model validation and documentation. Special types like UUID/datetime/Decimal/Enum/frozenset are also validated and converted by a single type
- Fold frequently-used constrained parameters into an Annotated type alias and reuse them (DRY). Output contract with response_model, format the 422 body in an exception handler. Always test boundary values and 422

---

"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](/case-studies/broadcaster-ai-content-platform); 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 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](/blog/fastapi-authentication-oauth2-jwt-security-scopes-production-guide), [boundary validation with Pydantic v2 here](/blog/pydantic-v2-production-validation-type-safety)).

---

## 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.**

```python
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(...)]`.**

```python
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.

```python
# 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.

```python
# (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.

```python
# 必須クエリ：デフォルト値を書かない。省略すると 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).

```python
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 (**<**)

```python
# 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.

```python
# 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.

```python
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."

```python
# 何も来なければ ["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`.

```python
# 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`.

```python
@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

```python
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.**

```python
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.

```json
{
    "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.

```python
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.**

```python
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.

```json
{
    "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.

```python
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.

```python
@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}
```

```json
{
    "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`.**

```python
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](/blog/pydantic-v2-production-validation-type-safety)).

---

## 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.) |

```python
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.

```python
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.

```python
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](/blog/fastapi-authentication-oauth2-jwt-security-scopes-production-guide)). In a large app, consolidate these shared types in `app/api/deps.py`, etc. ([project structure here](/blog/fastapi-project-structure-apirouter-dependencies-large-app-guide)).

### 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.

```python
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.

```python
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](/blog/fastapi-production-async-pydantic-observability-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."

```python
# 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](/blog/pydantic-v2-production-validation-type-safety)).

---

## 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 `if`s," "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)](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/) — `Annotated` × `Query`, `min_length`/`max_length`/`pattern`, `alias`/`deprecated`/`include_in_schema`, multiple values
- [Path Parameters and Numeric Validations (FastAPI)](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/) — `Path`, `ge`/`gt`/`le`/`lt`, argument order and `Annotated`
- [Query Parameter Models (FastAPI)](https://fastapi.tiangolo.com/tutorial/query-param-models/) — receiving queries with a Pydantic model, `model_config={"extra": "forbid"}`
- [Body - Multiple Parameters (FastAPI)](https://fastapi.tiangolo.com/tutorial/body-multiple-params/) — multiple bodies, `Body()`, `Body(embed=True)`, mixing path/query/body
- [Body - Fields (FastAPI)](https://fastapi.tiangolo.com/tutorial/body-fields/) — validating in-model with `Field` (import from `pydantic`)
- [Extra Data Types (FastAPI)](https://fastapi.tiangolo.com/tutorial/extra-data-types/) — special types like `UUID`/`datetime`/`Decimal`/`frozenset`
- [Pydantic data types](https://docs.pydantic.dev/latest/concepts/types/) — the list of data types Pydantic supports
