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(2026年11月13日公開)で、動作要件はPython 3.11以上です。フォームとファイルの受信に要るpython-multipartは本体の必須依存ではなく、standardのextraに含まれます。FastAPIを初めて入れる場合は、FastAPIの最短の起動手順とasync/defの使い分けを先に済ませてください。
# 仮想環境を作り、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によると、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の使い方で扱っています。
単一の値を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のとおり、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によると、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も、同じエンドポイントでFormとJSONのBodyは併用できないと明記しています。
bytesとUploadFileの違いとメモリに全部載せずに保存する書き方
公式の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 "[email protected];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は、Requestから直接読んだデータはFastAPIの検証・変換・OpenAPIへの掲載の対象外になると明記しています。/docsにボディの形が載らず、受け取る側の型チェックも自分で書くことになります。
判断基準は単純です。ボディを「値」として使うならモデルで受け、「バイト列」として使うときだけRequestで受けます。クライアントのIPやヘッダーを見たいだけなら、モデルの引数とrequest: Requestを並べて書けば両立します。このRequestはStarletteのrequestsのものなので、await request.json()やawait request.form()の細かな仕様はStarlette側の説明が正です。
RequestBodyLimitMiddlewareでボディサイズの上限を決める設定
アップロードを受けるAPIでは、上限を決めないと巨大なボディでディスクとメモリを埋められます。Starletteの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に全種類の説明があり、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つに収まります。上から順に確かめると早く当たります。
- JSONで受けるエンドポイントに、フォーム形式で送っている。Pythonの
requestsやhttpxでdata=を使うとフォームになる。JSONならjson=を使う - Formで受けるエンドポイントに、JSONで送っている。フロントエンドで
JSON.stringifyしているならFormDataに変える - ファイルの項目名が違う。サーバーの引数が
fileなのにuploadで送ると、["body", "file"]のmissingになる - 単一の値をボディで送っているのに、サーバー側に
Body()が無い multipart/form-dataのContent-Typeを手で書いて、区切り文字列(boundary)が欠けている。FormDataを使うときはヘッダーを自分で付けない
1と2は、送る側と受ける側の担当が分かれているチームで頻発します。/docsで実際に送ってみて通るなら、サーバーは正しく、クライアントの送り方が原因と切り分けられます。
RequestValidationErrorのハンドラで422の応答とログを整える
公式の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の使い方で扱っています。既存システムとのAPI連携の設計や、受け口の仕様づくりから相談したい場合は、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の表示・設定と本番で隠す手順を参照してください。
POSTを受けるエンドポイントはasync defとdefのどちらで書きますか?
中で呼ぶ処理で決めます。await request.body()や非同期のDBドライバを使うならasync def、ファイルの書き込みや同期のライブラリを呼ぶならdefです。async defの中で同期のファイル書き込みをすると、その間は同じワーカーの他のリクエストが止まります。考え方の詳細はFastAPIとは?の記事のasync/defの使い分けと同じです。
Flaskのrequest.get_json()やrequest.formに当たるものはどれですか?
直接の対応はawait request.json()とawait request.form()ですが、FastAPIではそれを使わず、引数の型で宣言するのが基本です。get_json()で受けて手で検証していた処理は、Pydanticモデル1つに置き換わります。移行の全体像はFlaskとFastAPIの違いを同じAPIで比較した記事で、同じエンドポイントを両方で書いて比べています。
422の英語のエラーメッセージを日本語にできますか?
できます。前の章の例外ハンドラで、typeごとに日本語の文言を割り当ててmsgを差し替える方法です。missingなら「必須項目です」、int_parsingなら「整数で入力してください」のように対応表を持ち、locの最後の要素を項目名として添えると、画面に出せる形になります。エラーの元になるモデル定義の書き方は、PydanticのBaseModelとV1からの変更点の解説で扱っています。
関連記事
- FastAPIとは?最短の起動手順とasync/defの使い分け、0.162の変更点:FastAPI単体の入門と起動手順です。
- Pydanticとは?読み方・BaseModelの使い方とV1との違い【2.14対応】:POSTのボディを受けるモデル定義の土台です。
- FastAPIのDependsの使い方|依存性注入・yield・テストの差し替えをコードで解説:受け取った後の認証やDB接続を共通化する方法です。
- FastAPIのSwagger UI(/docs)の使い方|表示・設定・本番で隠す手順【2026年版】:POSTを画面から試す手順と本番での扱いです。
- FlaskとFastAPIの違い|同じAPIを両方で書いて型検証・非同期・OpenAPIを比較:request.formからの移行を比較しています。