---
title: "FastAPIでPOSTを受け取る方法｜JSON・Form・ファイル・Requestの使い分けと422の直し方"
url: "https://www.issoh.co.jp/tech/details/19737/"
published: 2026-11-14
updated: 2026-11-14
categories: ["開発"]
publisher: "株式会社一創"
---

# FastAPIでPOSTを受け取る方法｜JSON・Form・ファイル・Requestの使い分けと422の直し方

FastAPIでPOSTのデータを受け取る方法は、送られてくる形式で4つに分かれます。JSONならPydanticモデル、HTMLフォームなら`Form`、ファイルなら`UploadFile`、署名検証のように生のバイト列が要るなら`Request`です。この記事では、FastAPI 0.162.6で小さなAPIを作りながら、それぞれの書き方と選び分け、JSONとファイルを1回で送りたいときの設計、ボディサイズの上限を確認します。後半では、POSTでつまずく原因の大半を占める422エラーについて、`detail`の読み方と直す箇所を整理します。

## まとめ：FastAPIのPOSTで受け口を選ぶ4つの型と422の結論

新しく作るAPIの受け口は、JSONをPydanticモデルで受ける形を基本にします。引数の型に`BaseModel`の子クラスを書くだけで、ボディの検証、型変換、OpenAPIへの掲載までFastAPIが行う仕組みです。`Form`を使うのはブラウザのフォームやOAuth2のパスワード方式から送られてくる場合に限り、ファイルは`UploadFile`で受けてメモリに全部載せないようにします。

`Request`から生のボディを読むのは、Webhookの署名検証のように「受け取ったバイト列そのもの」が要る場面だけです。422を避けるために`Request`へ逃げる書き方は採りません。検証とドキュメントを自分で捨てることになるからです。

422が返ったら、応答の`detail`にある`loc`と`type`を最初に見ます。原因の多くは、送る側の`Content-Type`と受ける側の宣言（JSONかFormか）の食い違い、フィールド名の不一致、必須項目の欠落の3つです。

## FastAPI 0.162系でPOSTのJSONボディをPydanticモデルで受ける手順

まずはJSONを受け取る基本形を動かし、FastAPIが引数をどこから読むかの規則を押さえます。

### fastapi\[standard\]を入れてPOSTを試す検証環境の作り方と版の確認

2026年11月14日時点のPyPIで、[FastAPIの最新は0.162.6](https://pypi.org/project/fastapi/)（2026年11月13日公開）で、動作要件はPython 3.11以上です。フォームとファイルの受信に要る`python-multipart`は本体の必須依存ではなく、`standard`のextraに含まれます。FastAPIを初めて入れる場合は、[FastAPIの最短の起動手順とasync/defの使い分け](https://www.issoh.co.jp/tech/details/3859/)を先に済ませてください。

```
# 仮想環境を作り、FastAPI（python-multipart込み）とpytestを入れる
python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]==0.162.6" pytest

# 入った版を確かめる（Starletteは1.6.0以上かも見る）
pip show fastapi starlette python-multipart | grep -E "^(Name|Version)"

# main.py を置いたディレクトリで開発サーバーを起動する
fastapi dev main.py
```

`pip install fastapi`だけで入れた環境では`python-multipart`が入りません。この状態で`Form`や`UploadFile`を使うルートを書くと、アプリの読み込み時に`python-multipart`を入れるよう求めるエラーで止まります。

### BaseModelを引数に書くとボディになる判定規則と最小のPOSTコード

[公式のRequest Body](https://fastapi.tiangolo.com/tutorial/body/)によると、FastAPIは引数を次の順で振り分けます。パスに同じ名前があればパスパラメータ、`int`や`str`のような単一の型ならクエリパラメータ、Pydanticモデルならリクエストボディです。

```
from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI()


class ItemIn(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: int = Field(ge=0)
    tags: list[str] = []


@app.post("/items", status_code=201)
def create_item(item: ItemIn):
    return {"name": item.name, "price": item.price, "tags": item.tags}
```

```
curl -X POST "http://127.0.0.1:8000/items" \
  -H "Content-Type: application/json" \
  -d '{"name": "pen", "price": 120}'
# => {"name":"pen","price":120,"tags":[]}
```

既定値のある`tags`は省略でき、`name`と`price`は必須です。`Field(ge=0)`のような制約もここで検証され、満たさなければ本体は実行されません。`Field`で書ける制約や`model_dump()`の使い方は、[PydanticのBaseModelの使い方](https://www.issoh.co.jp/tech/details/4838/)で扱っています。

### 単一の値をBody()で受ける書き方とembed=Trueで包むJSONの形

規則の2番目が落とし穴です。`def rename(name: str)`と書くと、`name`はボディではなくクエリパラメータになります。JSONで`{"name": "new"}`を送っても読まれず、`query`の`name`が無いという422が返ります。

```
from typing import Annotated

from fastapi import Body


@app.post("/items/{item_id}/rename")
def rename_item(item_id: int, name: Annotated[str, Body(embed=True)]):
    # 期待するボディは {"name": "new"}
    return {"id": item_id, "name": name}
```

[公式のBody – Multiple Parameters](https://fastapi.tiangolo.com/tutorial/body-multiple-params/)のとおり、`Body()`を付けると単一の値もボディから読みます。`embed=True`は、値を引数名のキーで包んだJSONを期待させる指定です。モデルの引数が2つ以上ある場合は、何も指定しなくても引数名がキーになります。項目が2つを超えるなら、`Body`を並べるよりモデルを1つ定義した方が読みやすくなります。

## HTMLフォームとファイルアップロードをFormとUploadFileで受ける実装

ブラウザのフォームやファイルは、JSONではなく`application/x-www-form-urlencoded`か`multipart/form-data`で届きます。受ける側の宣言も変えます。

### Form()とフォーム用のPydanticモデルでログイン項目を受け取る

項目が少なければ`username: Annotated[str, Form()]`のように1つずつ書けます。[公式のForm Models](https://fastapi.tiangolo.com/tutorial/request-form-models/)によると、0.113.0からはPydanticモデルにまとめて`Form()`を付ける書き方が使え、0.114.0からは想定外の項目を拒否できます。

```
from fastapi import Form


class LoginForm(BaseModel):
    model_config = {"extra": "forbid"}  # 宣言していない項目が来たら422
    username: str
    password: str


@app.post("/login")
def login(form: Annotated[LoginForm, Form()]):
    return {"username": form.username}
```

```
curl -X POST "http://127.0.0.1:8000/login" -d "username=alice&password=secret"
```

`curl -d`は`application/x-www-form-urlencoded`で送るので、このエンドポイントに合います。同じ内容を`-H "Content-Type: application/json"`付きのJSONで送ると、フォームとしては読めず422です。[公式のForm Data](https://fastapi.tiangolo.com/tutorial/request-forms/)も、同じエンドポイントでFormとJSONのBodyは併用できないと明記しています。

### bytesとUploadFileの違いとメモリに全部載せずに保存する書き方

[公式のRequest Files](https://fastapi.tiangolo.com/tutorial/request-files/)のとおり、`Annotated[bytes, File()]`で受けるとファイルの中身が全部メモリに載ります。数KBの画像なら問題ありませんが、PDFや動画では1リクエストごとにその大きさのメモリが必要です。`UploadFile`は一定の大きさまでメモリに置き、それを超えるとディスクの一時ファイルに書く仕組みなので、業務システムのアップロードはこちらで受けます。

```
import shutil
from pathlib import Path
from uuid import uuid4

from fastapi import HTTPException, UploadFile

UPLOAD_DIR = Path("uploads")
ALLOWED_TYPES = {"image/png", "image/jpeg", "application/pdf"}


@app.post("/files", status_code=201)
def upload_file(title: Annotated[str, Form()], file: UploadFile):
    if file.content_type not in ALLOWED_TYPES:
        raise HTTPException(415, "unsupported file type")
    UPLOAD_DIR.mkdir(exist_ok=True)
    # クライアントが送ったファイル名は保存先に使わない（拡張子だけ借りる）
    saved = f"{uuid4().hex}{Path(file.filename or '').suffix.lower()}"
    with (UPLOAD_DIR / saved).open("wb") as out:
        shutil.copyfileobj(file.file, out)  # 少しずつ読み書きし、全体をメモリに載せない
    return {"title": title, "saved": saved}
```

```
curl -X POST "http://127.0.0.1:8000/files" \
  -F "title=見積書" -F "file=@quote.pdf;type=application/pdf"
```

`file.filename`はクライアントが自由に付けられる値で、`../../`を含めて保存先の外へ書かせる攻撃に使われます。保存名はサーバー側で作ります。`content_type`も自己申告なので、受け入れる種類を絞るときは先頭のバイト列（PDFなら`%PDF`）も確かめるのが安全です。エンドポイントを`def`で書いているのは、ファイルの書き込みが同期処理だからで、FastAPIはこれをスレッドプールで実行します。

### JSONとファイルを1回のPOSTで送れない理由と回避する2つの設計

「メタ情報はJSON、本体はファイル」で1回に送りたい要望はよく出ます。ところが1つのリクエストのボディは1つの形式しか持てず、`multipart/form-data`と`application/json`を同時には名乗れません。公式も、これはFastAPIの制限ではなくHTTPの仕様だと説明しています。回避策は2つです。

1つ目は、JSONを文字列のままフォームの1項目として送り、サーバー側でモデルに通す方法です。

```
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError


class ReportMeta(BaseModel):
    title: str
    year: int


@app.post("/reports", status_code=201)
def create_report(meta: Annotated[str, Form()], file: UploadFile):
    try:
        report = ReportMeta.model_validate_json(meta)
    except ValidationError as e:
        # そのままだと500になるので、通常の検証エラーと同じ422に揃える
        raise RequestValidationError(e.errors(include_url=False))
    return {"title": report.title, "year": report.year, "filename": file.filename}
```

2つ目は、ファイルを先にアップロードしてIDを受け取り、そのIDを含むJSONを別のPOSTで送る2段階の設計です。ファイルが数十MBを超える、または途中で失敗したときに送り直す範囲を小さくしたいなら、2段階を選びます。数MBまでで画面から1回で送りたいだけなら、1つ目の方法で十分です。

## Requestオブジェクトで生のボディを読む場面と型宣言を捨てる代償

サブKWの「fastapi request」で探している人の多くは、`Request`から直接ボディを取り出す方法を知りたい人です。使ってよい場面は限られます。

### await request.body()で受けるWebhook署名検証の実装例と順序

決済サービスやチャットツールのWebhookは、ボディのバイト列にHMACの署名を付けて送ってきます。Pydanticモデルで受けると、FastAPIがJSONを読み直した後の値しか手に入らず、空白やキーの並びが変わると署名と一致しません。この場面が`Request`の出番です。

```
import hashlib
import hmac
import os

from fastapi import Request

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()


class PaymentEvent(BaseModel):
    id: str
    amount: int


@app.post("/webhooks/payment")
async def payment_webhook(request: Request):
    raw = await request.body()  # 受け取ったバイト列そのもの
    expected = hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(request.headers.get("x-signature", ""), expected):
        raise HTTPException(401, "invalid signature")
    try:
        event = PaymentEvent.model_validate_json(raw)  # 署名が合ってから型に通す
    except ValidationError as e:
        raise RequestValidationError(e.errors(include_url=False))
    return {"received": event.id}
```

順序は「生のバイト列で署名を確かめる、その後でモデルに通す」です。比較には`==`ではなく`hmac.compare_digest`を使い、一致までの時間から署名を推測されないようにします。ヘッダー名とハッシュ方式は送ってくるサービスごとに違うので、相手の仕様書に合わせて書き換えてください。

### Requestで読むと検証とOpenAPIから外れる点と併用時の判断基準

[公式のUsing the Request Directly](https://fastapi.tiangolo.com/advanced/using-request-directly/)は、`Request`から直接読んだデータはFastAPIの検証・変換・OpenAPIへの掲載の対象外になると明記しています。`/docs`にボディの形が載らず、受け取る側の型チェックも自分で書くことになります。

判断基準は単純です。ボディを「値」として使うならモデルで受け、「バイト列」として使うときだけ`Request`で受けます。クライアントのIPやヘッダーを見たいだけなら、モデルの引数と`request: Request`を並べて書けば両立します。この`Request`は[Starletteのrequests](https://starlette.dev/requests/)のものなので、`await request.json()`や`await request.form()`の細かな仕様はStarlette側の説明が正です。

### RequestBodyLimitMiddlewareでボディサイズの上限を決める設定

アップロードを受けるAPIでは、上限を決めないと巨大なボディでディスクとメモリを埋められます。[StarletteのMiddleware](https://starlette.dev/middleware/)に載っている`RequestBodyLimitMiddleware`は、ファイルとmultipartの区切りを含むボディ全体の大きさを数え、超えたら413を返します。Starletteが`max_body_size`を入れたのは1.6.0（2026年8月）です。

```
from starlette.middleware.body_limit import RequestBodyLimitMiddleware

# ファイルを含むボディ全体を10MBまでに制限する
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=10 * 1024 * 1024)
```

FastAPI 0.162.6が求めるStarletteは0.46.0以上なので、古いStarletteが残った環境ではこのimportが失敗します。`pip show starlette`で1.6.0以上かを確かめてください。フォームの項目数は`request.form()`の既定で`max_files=1000`、`max_fields=1000`、ファイル以外の1項目は`max_part_size=1024*1024`（1MB）までです。Nginxなどの前段でも`client_max_body_size`の上限を同じ値にそろえておくと、どちらで止まったかが追いやすくなります。

## POSTで返る422エラーのdetailを読んで原因を特定する手順

422は「リクエストは届いたが、宣言した形と合わない」という意味です。応答の本文に原因が書いてあるので、推測で直す前に読みます。

### detailのloc・type・msgから壊れている入力を突き止める読み方

FastAPIの既定の422応答は、`detail`の配列に入力の問題を1件ずつ並べます。`loc`は場所（`["body", "price"]`なら、ボディの`price`）、`type`は種類、`msg`は説明、`input`は実際に届いた値です。

```
{
  "detail": [
    {
      "type": "int_parsing",
      "loc": ["body", "price"],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "abc"
    }
  ]
}
```

`type`はPydanticのエラー種別です。[PydanticのValidation Errors](https://pydantic.dev/docs/validation/latest/errors/validation%5Ferrors/)に全種類の説明があり、POSTでよく見るのは次の5つです。

| type                    | 意味          | まず直す箇所           |
| ----------------------- | ----------- | ---------------- |
| missing                 | 必須項目が無い     | キー名とContent-Type |
| json\_invalid           | JSONとして読めない | カンマ・引用符の崩れ       |
| int\_parsing            | 整数に変換できない   | 送る値の型            |
| model\_attributes\_type | 辞書として読めない   | 配列や文字列を送っていないか   |
| extra\_forbidden        | 宣言外の項目がある   | 余計なキーの削除         |

`loc`の先頭も手がかりです。`body`ならボディ、`query`ならクエリパラメータとして探されています。ボディで送ったつもりなのに`query`と出たら、単一の値に`Body()`を付け忘れています。

### POSTで422が出る典型の5パターンとクライアント側で直す箇所

実務で422が出る原因は、ほぼ次の5つに収まります。上から順に確かめると早く当たります。

1. JSONで受けるエンドポイントに、フォーム形式で送っている。Pythonの`requests`や`httpx`で`data=`を使うとフォームになる。JSONなら`json=`を使う
2. Formで受けるエンドポイントに、JSONで送っている。フロントエンドで`JSON.stringify`しているなら`FormData`に変える
3. ファイルの項目名が違う。サーバーの引数が`file`なのに`upload`で送ると、`["body", "file"]`の`missing`になる
4. 単一の値をボディで送っているのに、サーバー側に`Body()`が無い
5. `multipart/form-data`の`Content-Type`を手で書いて、区切り文字列（boundary）が欠けている。`FormData`を使うときはヘッダーを自分で付けない

1と2は、送る側と受ける側の担当が分かれているチームで頻発します。`/docs`で実際に送ってみて通るなら、サーバーは正しく、クライアントの送り方が原因と切り分けられます。

### RequestValidationErrorのハンドラで422の応答とログを整える

[公式のHandling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/)のとおり、422の応答は`RequestValidationError`の例外ハンドラで差し替えられます。既定の応答には`input`が入るため、ログインのフォームなら誤った`password`がそのまま返り、ログにも残りかねません。

```
import logging

from fastapi.responses import JSONResponse

logger = logging.getLogger("api")


def _clean(text: str) -> str:
    # 改行を混ぜてログ行を偽装されないよう、CRとLFを取り除く
    return text.replace("\r", "").replace("\n", "")


@app.exception_handler(RequestValidationError)
async def on_validation_error(request: Request, exc: RequestValidationError):
    errors = [
        {"loc": [_clean(str(p)) for p in e["loc"]], "type": e["type"], "msg": e["msg"]}
        for e in exc.errors()
    ]
    # 入力値（input）はログにも応答にも出さず、場所と種類だけ残す
    logger.warning(
        "validation failed path=%s errors=%s",
        _clean(request.url.path),
        [(".".join(e["loc"]), e["type"]) for e in errors],
    )
    return JSONResponse(status_code=422, content={"detail": errors})
```

`loc`にはサーバーが決めたフィールド名だけでなく、`extra_forbidden`のときはクライアントが送ってきたキー名も入ります。外から来た文字列として扱い、CRとLFを除いてから出力する手順です。公式も、例外を文字列化してそのまま返すと内部の情報が漏れると警告しています。

## POSTの受け口をJSON・Form・Requestから選ぶ判断基準と見送る場面

ここまでの内容を、設計の段階で迷わないための基準に落とします。

### 送り手ごとに受け口を決める判断表とRequestを使わない場面

| 送り手              | 受け口               | 理由             |
| ---------------- | ----------------- | -------------- |
| SPA・スマホアプリ・他社API | Pydanticモデル（JSON） | 検証とOpenAPIが自動  |
| HTMLのformタグ      | Form・フォームモデル      | ブラウザがJSONを送らない |
| ファイルを添付する画面      | UploadFile＋Form   | メモリに全部載せない     |
| 署名付きWebhook      | Request.body()    | 生のバイト列で照合する    |

自社で送り手も作るなら、フォーム画面でもJavaScriptからJSONで送り、受け口をモデルに統一します。`Form`は、JavaScriptを使わない画面や、OAuth2のパスワード方式のように仕様で決まっている場合に残します。

採らない書き方もはっきりさせておきます。「422が出て原因が分からないので`Request`で受けて`await request.json()`する」は採用の対象外です。エラーは消えますが、型の保証も`/docs`の記載も失い、壊れたデータが奥の処理まで流れ込みます。422は直す箇所を教えてくれる情報なので、前の章の手順で読み解きます。

### 外部システムとの連携でPOSTの仕様を先に固めるときの線引き

基幹システムや取引先のシステムからPOSTを受ける場合は、コードより先に「形式（JSONかmultipartか）」「上限サイズ」「署名の方式」「422のときに返す本文」を相手と文書で合意します。この4つが曖昧なまま実装すると、結合試験で422と413の原因探しに時間を取られるためです。FastAPIで書いた受け口の型は、そのままOpenAPIの仕様書として相手に渡せるので、モデルで受ける設計はこの合意にも役立ちます。

認証や共通の前処理を依存関数にまとめる方法は、[FastAPIのDependsの使い方](https://www.issoh.co.jp/tech/details/19720/)で扱っています。既存システムとのAPI連携の設計や、受け口の仕様づくりから相談したい場合は、[API開発・システム連携の受託開発](https://www.issoh.co.jp/service/system/api/)で受け付けています。

## よくある質問

FastAPIでPOSTを受け取る実装の途中で出てくることの多い質問に、2026年11月時点の版で答えます。

### POSTとPUT・PATCHで受け取り方の書き方は変わりますか？

変わりません。`@app.put`や`@app.patch`でも、Pydanticモデル・`Form`・`UploadFile`の宣言はPOSTと同じ規則で読まれます。違いは意味の側にあり、PATCHで一部の項目だけを更新するなら、モデルの全項目を`None`許容にして、`item.model_dump(exclude_unset=True)`で送られてきた項目だけを取り出す形です。送られなかった項目を`None`で上書きしてしまう事故を防げます。

### Swagger UIからファイルを付けてPOSTを試すことはできますか？

できます。`UploadFile`を引数に持つエンドポイントは、`/docs`の「Try it out」でファイル選択の欄が表示され、そのまま`multipart/form-data`で送れます。クライアントのコードを書く前に、サーバー側が期待どおりに受け取れるかを確かめる手段として便利です。本番で`/docs`を隠す設定は、[FastAPIのSwagger UIの表示・設定と本番で隠す手順](https://www.issoh.co.jp/tech/details/2246/)を参照してください。

### POSTを受けるエンドポイントはasync defとdefのどちらで書きますか？

中で呼ぶ処理で決めます。`await request.body()`や非同期のDBドライバを使うなら`async def`、ファイルの書き込みや同期のライブラリを呼ぶなら`def`です。`async def`の中で同期のファイル書き込みをすると、その間は同じワーカーの他のリクエストが止まります。考え方の詳細は[FastAPIとは？の記事のasync/defの使い分け](https://www.issoh.co.jp/tech/details/3859/)と同じです。

### Flaskのrequest.get\_json()やrequest.formに当たるものはどれですか？

直接の対応は`await request.json()`と`await request.form()`ですが、FastAPIではそれを使わず、引数の型で宣言するのが基本です。`get_json()`で受けて手で検証していた処理は、Pydanticモデル1つに置き換わります。移行の全体像は[FlaskとFastAPIの違いを同じAPIで比較した記事](https://www.issoh.co.jp/tech/details/19695/)で、同じエンドポイントを両方で書いて比べています。

### 422の英語のエラーメッセージを日本語にできますか？

できます。前の章の例外ハンドラで、`type`ごとに日本語の文言を割り当てて`msg`を差し替える方法です。`missing`なら「必須項目です」、`int_parsing`なら「整数で入力してください」のように対応表を持ち、`loc`の最後の要素を項目名として添えると、画面に出せる形になります。エラーの元になるモデル定義の書き方は、[PydanticのBaseModelとV1からの変更点の解説](https://www.issoh.co.jp/tech/details/4838/)で扱っています。

## 関連記事

- [FastAPIとは？最短の起動手順とasync/defの使い分け、0.162の変更点](https://www.issoh.co.jp/tech/details/3859/)：FastAPI単体の入門と起動手順です。
- [Pydanticとは？読み方・BaseModelの使い方とV1との違い【2.14対応】](https://www.issoh.co.jp/tech/details/4838/)：POSTのボディを受けるモデル定義の土台です。
- [FastAPIのDependsの使い方｜依存性注入・yield・テストの差し替えをコードで解説](https://www.issoh.co.jp/tech/details/19720/)：受け取った後の認証やDB接続を共通化する方法です。
- [FastAPIのSwagger UI（/docs）の使い方｜表示・設定・本番で隠す手順【2026年版】](https://www.issoh.co.jp/tech/details/2246/)：POSTを画面から試す手順と本番での扱いです。
- [FlaskとFastAPIの違い｜同じAPIを両方で書いて型検証・非同期・OpenAPIを比較](https://www.issoh.co.jp/tech/details/19695/)：request.formからの移行を比較しています。

---

出典: [FastAPIでPOSTを受け取る方法｜JSON・Form・ファイル・Requestの使い分けと422の直し方](<https://www.issoh.co.jp/tech/details/19737/>)（株式会社一創）
