GitHubのトレンドに fastapi/fastapi が載っていました。スターは103,408。いまさらトレンドに出てくるような新顔ではないので、何かあったのかとリリース一覧を開いたら、理由らしきものが並んでいました。
0.147.0 から 0.159.1 まで、PyPI の公開時刻で数えて24回。最初が10月19日 09:48(UTC)、最後が10月21日 20:59(UTC)なので、2日と11時間ほどで minor の数字が146から159まで上がったことになります。
FastAPI は 0.x のまま minor で互換性の崩れる変更を入れてくるライブラリなので、私はバージョンを == で固定しておき、上げるときにまとめて確認するようにしてきました。ただ、24本のリリースノートを読んで、手元のコードへの影響を頭の中で判定するのはさすがにしんどい。しかもリリースノートには「〜に対応した」とは書いてあっても、対応する前にそのコードがどう振る舞っていたかまでは書いてありません。それなら全部の版を入れて同じコードを流し、出力が変わった版を数えたほうが早い、と考えました。
リポジトリのコミットを git bisect で二分探索する手もありますが、利用者が実際に入れるのは PyPI に公開されたパッケージです。今回は公開された版をそのまま並べることにしました。
試した環境
- 使い捨ての Docker コンテナ(
python:3.12-bookworm。Debian 12.14 / Python 3.12.13、--memory=4g --cpus=2) - 対象: FastAPI 0.146.0(10月19日より前の最後の版)から 0.159.1 までの25版
- 依存: 25版すべてで starlette 1.9.0 / pydantic 2.14.1 / httpx 0.28.1 / python-multipart 0.0.32 に揃った
- 公式チュートリアルのコード:
git clone --depth 1 -b 0.159.1で取得(commit 4bdfbe4513)。このタグのファイルを全版でそのまま使った - 画面の撮影: コンテナ内の Playwright 1.64.0(Chrome Headless Shell 156.0.8078.4)
コンテナを立ててから捨てるまでは3分57秒でした。
uv を入れて25版を並べ終えるまで26秒
1版につき1つの仮想環境(venv)を作り、uv で入れました。README も uv を勧めていますし、25版ぶん入れるので速さで選びました。
# コンテナ内
$ pip install uv # real=3.533
$ uv --version
uv 0.13.5 (x86_64-unknown-linux-gnu)
$ git clone -q --depth 1 -b 0.159.1 https://github.com/fastapi/fastapi /work/fastapi # real=4.251
commit=4bdfbe4513084b19e22421ce1f916d8c1ac462a0 date=2026-10-21T22:49:11+02:00
# 1版につき1つの venv(V は 0.146.0 0.147.0 … 0.159.1 の25版)
$ uv venv -q -p /usr/local/bin/python3.12 /v/$V
$ uv pip install -q -p /v/$V/bin/python "fastapi==$V" httpx python-multipart
0.146.0 fastapi 0.146.0 starlette 1.9.0 pydantic 2.14.1 httpx 0.28.1 multipart 0.0.32 sec 0.73
0.147.0 fastapi 0.147.0 starlette 1.9.0 pydantic 2.14.1 httpx 0.28.1 multipart 0.0.32 sec 0.22
(中略。25版とも starlette / pydantic / httpx / multipart は同じ版)
0.159.1 fastapi 0.159.1 starlette 1.9.0 pydantic 2.14.1 httpx 0.28.1 multipart 0.0.32 sec 0.18
setup wall=26s
# ここは改善の余地あり: 依存は「今日の最新」で揃えている。
# 各版の公開当日に入れた人の starlette / pydantic とは違う可能性があり、そこは今回の比較の外
2版目からはキャッシュが効いて、1版0.2秒前後です。uv の導入と clone を含めても、ここまでで26秒でした。依存が全版で同じ版に揃ったので、違うのは FastAPI だけ、という状態で比べられます。
4つのコードを、1組1プロセスで流す
流したのは次の4つです。
- A: 公式チュートリアル
docs_src/query_param_models/tutorial003_an_py310.py。クエリパラメータをPagination(limit・offset)とFilters(tags)の2つのモデルに分け、searchと並べたもの。ドキュメントに載っている/items/?limit=5&offset=10&tags=red&tags=blue&search=shirtを送る - B: 同じく
tutorial004_an_py310.py。日付の範囲DateRange(start・end)を| None = Noneで省略できるようにしたもの - C:
Query(x_internal_note="legacy")のように、Query に知らない名前の引数を渡す古い書き方(自作) - D: フォームで
agree: boolを必須にした登録 API(自作)。引数で受ける版と、フォームモデル(nickname: str = "guest"付き)で受ける版の2本
版ごとに venv が違うのでプロセスは当然分かれますが、コードごとにも分けました。前のコードで登録したルートが次のコードに残らないようにするためです。25版×4つで100プロセス、合計24秒でした。
結果を、出力が変わった版で区切るとこうなります。
| コード | 変わる前 | 変わった版 | 変わった後 |
|---|---|---|---|
| A クエリのモデルを2つ並べる(公式) | 0.146.0〜0.156.8: 起動はする。ドキュメントのURLを送ると422 | 0.157.0 | 200。OpenAPIのパラメータが limit・offset・tags・search に分かれる |
| B 省略できるクエリのモデル(公式) | 0.146.0〜0.158.2: importした時点でAssertionError | 0.159.0 | 何も送らないと200で date_range は null。start だけだと422 |
| C Query に知らない名前の引数 | 0.146.0〜0.154.0: 動く。引数はスキーマに入る。警告は0件 | 0.155.0 | importした時点でTypeError |
| D-1 必須の bool を送らない | 0.146.0: 422(agree が無い) | 0.147.0 | 200。agree は false |
| D-2 nickname に空文字を送る | 0.147.0〜0.148.0: 空文字のまま | 0.149.0 | 既定値の “guest” になる |
変わり目は5か所。どれもリリースノートの1行と対応していました(0.157.0「Add support for combining query, header, and cookie parameter models」、0.159.0「Add support for optional query, header, and cookie parameter models」、0.155.0「Remove deprecated **extra from request parameters」、0.147.0「Treat omitted boolean form fields as False」、0.149.0「Treat empty scalar values as missing in form models」)。
面白かったのは、変わる前の「壊れ方」が3通りに分かれたことです。起動の時点で落ちるもの(B・C)、起動はするのに特定の要求だけ422になるもの(A)、そしてエラーにならずに値の意味だけが変わるもの(D)。
A と B の実際の出力はこうでした。
# A(tutorial003)0.156.8 まで
GET /items/?limit=5&offset=10&tags=red&tags=blue&search=shirt -> 422
{"detail": [{"type": "missing", "loc": ["query", "pagination"], "msg": "Field required", "input": null}, {"type": "missing", "loc": ["query", "filters"], "msg": "Field required", "input": null}]}
OpenAPI のパラメータ: query:pagination* query:filters* query:search (* は required)
# A 0.157.0 から
GET /items/?limit=5&offset=10&tags=red&tags=blue&search=shirt -> 200
{"pagination": {"limit": 5, "offset": 10}, "filters": {"tags": ["red", "blue"]}, "search": "shirt"}
OpenAPI のパラメータ: query:limit query:offset query:tags query:search
# B(tutorial004)0.158.2 まで: import した時点で
AssertionError: Query parameter 'date_range' must be one of the supported types
# B 0.159.0 から
GET /items/ -> 200 {"limit": 10, "date_range": null}
GET /items/?start=2026-10-01 -> 422 {"detail": [{"type": "missing", "loc": ["query", "end"], "msg": "Field required", "input": {"start": "2026-10-01"}}]}
GET /items/?start=2026-10-01&end=2026-10-31 -> 200 {"limit": 10, "date_range": {"start": "2026-10-01", "end": "2026-10-31"}}
A の古い版は、モデルを「pagination という名前の1つのクエリパラメータ」として扱っていて、limit や offset はパラメータの名前としてはどこにも出てきません。起動時に何も言われないぶん、B より気づきにくい壊れ方です。
0.156.8 の /docs は、自分で組んだ URL に422を返していた
A の気づきにくさを画面でも見ておきたくて、README と同じく uv で fastapi[standard] を入れ(0.156.8 で59パッケージ・63MB・1.1秒)、同じ tutorial003 を 0.156.8 と 0.159.1 でそれぞれ fastapi run しました。Swagger UI(/docs)は、ホストのブラウザではなくコンテナ内の Chromium で開いています。

0.156.8 では、pagination と filters が「必須の object」として出ます。入力欄にはモデルの既定値と同じ値の JSON があらかじめ入っていて、そのまま Execute を押すと、Swagger UI は中身を展開して ?limit=10&offset=0&tags=%5B%5D を組み立てました。ところが同じサーバーは、pagination と filters という名前のクエリが無いと言って422を返します。
画面が作った要求を、その画面を出しているサーバー自身が受け付けない。ドキュメントを見ながら手で叩いていたら、まず自分の入力ミスを疑ったと思います。

0.159.1 では入力欄が4つに分かれ、limit=5・offset=10・tags=red・search=shirt を入れて Execute すると200が返りました。こちらはドキュメントの説明どおりです。
ここで脱線しそうになるのが私の悪い癖なのですが、0.156.8 の画面が組み立てた URL を、そのまま 0.159.1 に投げてみました。
# コンテナ内から、0.156.8 の画面が作った URL を両方に送る
$ curl -s "http://127.0.0.1:8001/items/?limit=10&offset=0&tags=%5B%5D" # 0.156.8
{"detail":[{"type":"missing","loc":["query","pagination"],"msg":"Field required","input":null},{"type":"missing","loc":["query","filters"],"msg":"Field required","input":null}]} [422]
$ curl -s "http://127.0.0.1:8002/items/?limit=10&offset=0&tags=%5B%5D" # 0.159.1
{"pagination":{"limit":10,"offset":0},"filters":{"tags":["[]"]},"search":null} [200]
# 0.156.8 のログ(1行目が画面の Execute、2行目が上の curl)
INFO: 127.0.0.1:45130 - "GET /items/?limit=10&offset=0&tags=%5B%5D HTTP/1.1" 422 Unprocessable Entity
INFO: 127.0.0.1:38400 - "GET /items/?limit=10&offset=0&tags=%5B%5D HTTP/1.1" 422 Unprocessable Entity
# 0.159.1 のログ(同じ順)
INFO: 127.0.0.1:38100 - "GET /items/?limit=5&offset=10&tags=red&search=shirt HTTP/1.1" 200 OK
INFO: 127.0.0.1:46092 - "GET /items/?limit=10&offset=0&tags=%5B%5D HTTP/1.1" 200 OK
0.159.1 では200で通りますが、tags は ["[]"] です。空の配列のつもりで送られた [] という文字列が、1件のタグとして入ります。古い画面や古いクライアントが組み立てた URL が新しい版に届いた場合、エラーにならずにタグが1つ増える、というエッジケースです。
警告が一度も出ないまま、引数が消えた
C は、Query() に知らない名前の引数を渡すと、それが OpenAPI のスキーマにそのまま入る、という古い書き方です。0.155.0 のリリースノートでは「非推奨だった **extra を消した」という扱いになっています。
非推奨なら、それまでの版で警告が出ていたはずです。そう思って 0.146.0 と 0.154.0 で python -W always を付け、出た警告をすべて記録してみました。
# 0.146.0 / 0.154.0: -W always で import しても
warnings recorded: 0
# 0.146.0 の fastapi/param_functions.py(非推奨の印は型注釈の中だけ)
**extra: Annotated[
Any,
Doc(
"""
Include extra fields used by the JSON Schema.
"""
),
deprecated(
"""
The `extra` kwargs is deprecated. Use `json_schema_extra` instead.
"""
),
# 0.155.0 以降: import した時点で
TypeError: Query() got an unexpected keyword argument 'x_internal_note'
# 書き換え後 Query(json_schema_extra={"x_internal_note": "legacy"}) の OpenAPI
0.154.0 {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "x_internal_note": "legacy", "title": "Q"}}
0.159.1 {"name": "q", "in": "query", "required": false, "schema": {"anyOf": [{"type": "string"}, {"type": "null"}], "x_internal_note": "legacy", "title": "Q"}}
警告は0件でした。非推奨の印は、引数の型注釈の中にある deprecated(...) だけです。一方、同じ 0.154.0 の params.py を見ると、example と regex には warnings.warn(..., category=FastAPIDeprecationWarning) があり、こちらは実行時に知らせてくれます。同じ「非推奨」でも、扱いが分かれていたわけです。
移行前と移行後も比べました。最初のアプローチ(Query(x_internal_note="legacy"))を 0.154.0 まで動かしたときの OpenAPI のパラメータと、改善後のアプローチ(json_schema_extra の辞書に包む)を 0.154.0 と 0.159.1 で動かしたときのものは、文字列として完全に一致しました。直すこと自体は1行で済むんですよね。困るのは、直す必要があることを実行時に一度も知らされないまま、ある版で import が落ちる点です。
実行時の警告を CI で拾って直す、という運用ではこの消え方には気づけません。エディタや型チェッカーがこの印を拾って教えてくれるかどうかは、今回は確かめていません。
送らなかった bool が False になる
D はフォームです。利用規約への同意のようなチェックボックスを想定して agree: bool を必須にした API に、agree を付けずに POST しました。
# 0.146.0
POST /signup/ email=a@example.com -> 422 missing: body.agree
POST /signup-model/ email=a@example.com -> 422 missing: body.agree
# 0.147.0〜0.148.0
POST /signup/ email=a@example.com -> 200 {"email": "a@example.com", "agree": false}
POST /signup-model/ email=a@example.com&nickname= -> 200 {"email": "a@example.com", "agree": false, "nickname": ""}
# 0.149.0 から
POST /signup-model/ email=a@example.com&nickname= -> 200 {"email": "a@example.com", "agree": false, "nickname": "guest"}
0.147.0 からは、agree を送らない要求が 200 で通り、agree は false になります。引数で受けてもフォームモデルで受けても同じでした。ブラウザのフォームはチェックを外したチェックボックスを送らないので、ブラウザから使う前提ならこちらのほうが自然だ、という変更だと読めます。0.150.0 の「Treat unselected browser file inputs as missing」や 0.153.0 の「Apply browser form handling to unions of Pydantic models」も、同じ方向の変更です。
ただ、私が気になったのは「必須」の意味が変わることです。0.146.0 までは、agree を送ってこないクライアントは422で止まりました。0.147.0 からは、同意欄を出し忘れたフォームや、agree を付け忘れた API クライアントからの要求も、200 で通ります。同意を必須にしているつもりの画面で、サーバー側の検証が「送られてこなかった」を検出しなくなるわけです。ここは好みが分かれるところですが、規約への同意のような項目は、bool の必須に頼らず、値が true であることまで検証しておくほうが安全だと考えています。
0.149.0 では、もう1つ変わりました。フォームモデルの nickname: str = "guest" に空文字を送ると、0.148.0 までは空文字のまま、0.149.0 からは既定値の “guest” になります。「空にした」と「送らなかった」が区別されなくなるので、プロフィールの項目を空にして保存する、といった画面とは相性が悪そうです(そこまでは試していません)。
もう1つ脱線すると、テストクライアント(TestClient)を読み込んだ回はすべて、Starlette 1.9.0 が「Using httpx with starlette.testclient is deprecated; install httpx2 instead.」という警告を標準エラーに出していました。これは FastAPI の版によらず同じで、Starlette 側の話です。FastAPI 0.159.1 の standard の依存には、httpx と並んで httpx2 も入っています。
試してみての所感
正直なところ、2日半で24回という数字を見たときは、追いかけるのは無理だと思いました。全部入れて流してみると、変わり目は5か所で、どれもリリースノートの1行と対応していました。リリースは多いけれど、1回ごとの変更は小さく、書いてあるとおりに変わる。そこは安心材料です。
一方で、壊れ方は揃っていません。起動で落ちる B と C は、上げた瞬間に気づけます。起動はするのに特定の要求だけ422になる A は、テストかドキュメント画面で実際に叩かないと気づけません。D はエラーにすらならず、値の意味だけが変わります。今思えば、24本のリリースノートを読み込むより、手元のコードを全版に流すほうが、この3つの区別ははっきりつきました。
私は今後も FastAPI のバージョンは固定しておき、上げるときは「上げる前の版と後の版で同じ要求を流して、応答を比べる」確認を挟むつもりです。0.157.0 からのモデルの組み合わせと、0.159.0 からの省略できるモデルは、クエリの多い一覧 API で素直に使えそうなので、次に新しく書く API から使っていきたいところです。フォームの bool と空文字の扱いは、同意やプロフィール編集の画面を持つサービスだと影響が出るので、しばらく様子見にします。