FastAPIのレスポンスを返すたびに、Pydanticのシリアライズがプロファイラの上位に出てくる。エンドポイントのロジックは軽いのに、JSONへの変換とバリデーションで時間を食う。そこで手元のAPIで試したのが msgspec です。
この記事は msgspec 0.21.1 と Pydantic 2.13.4、比較用に orjson 3.11.9 を Python 3.13.7 で動かして書いています。msgspec は Python 3.10 以上が必要です。
速さの出どころと、引き換えに手放すもの
msgspec はバリデーション付きのシリアライザです。Pydantic が「設定管理・ORM連携・豊富なバリデーションAPIまで含む総合フレームワーク」なのに対し、msgspec は encode / decode と型検証だけに範囲を絞っています。コアは Rust 製で、型ヒントからスキーマ専用のコードパスをコンパイルする設計。公式トップは自身の特徴を “Zero-cost schema validation” と表現しています。
設計思想の差を1枚で見る
両者は守備範囲が違うので、速度だけで優劣はつきません。
| 観点 | msgspec 0.21.1 | Pydantic 2.13.4 |
|---|---|---|
| 主目的 | 高速な encode/decode + 型検証 | 総合データバリデーション |
| 検証の姿勢 | 明示的・最小限 | 自動・包括的 |
| 設定管理 | なし | pydantic-settings |
| カスタム検証 | __post_init__ 中心 | field/model validator が豊富 |
| エコシステム | 狭い | 広い(FastAPI 等が標準採用) |
「ゼロコスト」の意味
ここでの zero-cost は、検証のために別途スキーマオブジェクトを走らせないという意味です。型ヒントがそのままデコード時の検証ルールになり、整数や文字列のような頻出型は新しい Python オブジェクトを介さずデコードできる。公式は JSON について 「orjson 単体のデコードより速く、しかも検証まで済ませる」と主張しています。手元の数字は後段で出します。
Structでスキーマを書いてencode/decodeする
動かして確かめます。モデルは msgspec.Struct を継承して型注釈を並べるだけ。
import msgspec
from msgspec import Struct
class User(Struct):
id: int
name: str
email: str
data = b'{"id": 1, "name": "moha", "email": "moha@example.com"}'
user = msgspec.json.decode(data, type=User)
print(user)
print(msgspec.json.encode(user))
実行結果。decode の type= に Struct を渡すと、その型に沿って検証しながらインスタンス化します。
User(id=1, name='moha', email='moha@example.com')
b'{"id":1,"name":"moha","email":"moha@example.com"}'
型が合わなければデコード時に落ちる
検証は decode の最中に走ります。id に文字列を渡すと msgspec.ValidationError。エラーは JSON Pointer 形式でどのフィールドかを指します。
try:
msgspec.json.decode(b'{"id": "abc", "name": "x", "email": "y"}', type=User)
except msgspec.ValidationError as e:
print(e)
Expected `int`, got `str` - at `$.id`
どこで型が壊れたかが $.id で分かるので、ログに出せばそのまま原因調査に使えます。
制約バリデーションはAnnotatedとMetaで書く
「0以上」「3文字以上」のような値の制約は、型に msgspec.Meta を Annotated で添える形で表現します。Pydantic の Field(ge=0) に対応する書き方です。
from typing import Annotated
from msgspec import Struct, Meta
import msgspec
class Account(Struct):
age: Annotated[int, Meta(ge=0, le=150)]
handle: Annotated[str, Meta(min_length=3, max_length=20)]
print(msgspec.json.decode(b'{"age": 30, "handle": "moha"}', type=Account))
try:
msgspec.json.decode(b'{"age": -1, "handle": "moha"}', type=Account)
except msgspec.ValidationError as e:
print(e)
Account(age=30, handle='moha')
Expected `int` >= 0 - at `$.age`
使える制約
数値の ge / gt / le / lt / multiple_of、文字列やコレクションの min_length / max_length、文字列の pattern など。詳細は公式の Constraints ページにまとまっています。Pydantic のように検証関数を自由に書く方向ではなく、宣言的な制約を型に貼る方向です。
Strict vs Lax Mode
デコードの型変換には2つのモードがあります。公式 Usage の “Strict vs Lax Mode” セクションが該当箇所。デフォルトの strict は "30"(文字列)を int の 30 として受けません。lax にすると環境変数やクエリ文字列のような「すべて文字列で届く」入力を数値・真偽値へ寄せて読みます。API のボディは strict、設定値の読み込みは lax、と入力の出どころで切り替えるのが現実的です。
実測: Pydantic v2と同じ条件でベンチを取る
公式や他のベンチを引くだけでは足りない。5フィールドのフラットなモデルで decode+検証と encode を手元で測りました。timeit で20万回、同じマシンで2回流してブレを確認しています。
import timeit, json
import msgspec
from msgspec import Struct
from pydantic import BaseModel
class UserMS(Struct):
id: int; name: str; email: str; age: int; active: bool
class UserPD(BaseModel):
id: int; name: str; email: str; age: int; active: bool
raw = json.dumps({"id":1,"name":"moha","email":"moha@example.com",
"age":30,"active":True}).encode()
N = 200_000
ms = timeit.timeit(lambda: msgspec.json.decode(raw, type=UserMS), number=N)
pd = timeit.timeit(lambda: UserPD.model_validate_json(raw), number=N)
print(f"decode+validate msgspec {ms/N*1e6:.3f} us | pydantic {pd/N*1e6:.3f} us | x{pd/ms:.1f}")
2回流して、ほぼ同じ値でした。
decode+validate msgspec 0.112 us | pydantic 0.465 us | x4.1
encode msgspec 0.069 us | pydantic 0.465 us | x6.8
instance sizeof msgspec 72 bytes | pydantic 72 bytes
数字の読み方
手元では decode+検証で約4.1倍、encode で約6.8倍。1件あたり 0.1〜0.5 マイクロ秒の世界なので、数件返すだけのエンドポイントでは体感差はほぼ出ません。効くのは数千件のリストを返す箇所や、メッセージを秒間数万件さばくワーカー。そこで初めて 4〜7倍が積み上がります。
メモリは期待ほど変わらなかった
速度とは別に、インスタンスのメモリも測りました。結果は 両方とも 72 バイトで同じ。Pydantic v2 も内部はスロット相当で、フラットなモデルではインスタンスサイズに差は出ません。msgspec の優位は割り当てを減らしたデコード経路にあって、オブジェクトを小さくすることではない、と読めます。
引用する数字は出どころを分ける
上の 4.1倍 / 6.8倍 は手元の測定値です。公式や他のベンチが出す数字は条件が違います。msgspec 公式は Struct 生成が代替手段の 5〜60倍、複数のベンチが pydantic v2 比で decode/encode 2〜5倍。モデルの深さ・フィールド数・計測方法で倍率は動くので、自分のペイロードで測るのが結局いちばん早い。
APIレスポンス整形に効く小技
速さ以外に、レスポンスを組むときに手数が減る機能がいくつかあります。
rename=camelでキャメルケースに寄せる
Python 側は snake_case、API は camelCase、という食い違いはクラス定義時の rename 一発で吸収できます。公式 Structs の “Renaming Fields” に該当。
class Item(Struct, rename="camel"):
item_id: int
unit_price: int
print(msgspec.json.encode(Item(item_id=10, unit_price=300)))
b'{"itemId":10,"unitPrice":300}'
omit_defaultsで初期値を落とす
デフォルト値のままのフィールドを出力から省くと、ペイロードが小さくなります。omit_defaults=True をクラスに付けるだけ。
class Config(Struct, omit_defaults=True):
host: str = "localhost"
port: int = 8080
debug: bool = False
print(msgspec.json.encode(Config(host="localhost", port=8080, debug=True)))
b'{"debug":true}'
デフォルトと同じ host と port が消え、変更した debug だけが残ります。公式 Structs の “Omitting Default Values” の挙動です。
Tagged Unionsで多態レスポンスを型で扱う
成功と失敗で形が違うレスポンスは、タグ付きの union として表現できます。tag でクラスに識別子を持たせると、デコード時に type フィールドを見て自動で振り分けます。
class Success(Struct, tag="success"):
value: int
class Error(Struct, tag="error"):
message: str
for raw in [b'{"type":"success","value":42}',
b'{"type":"error","message":"boom"}']:
print(msgspec.json.decode(raw, type=Success | Error))
Success(value=42)
Error(message='boom')
公式 Structs の “Tagged Unions”。match 文と組み合わせれば、デコード結果を型で分岐できます。
dictへの変換とOpenAPIスキーマ生成
Struct はそのままでは dict ではありません。既存コードに混ぜるときは変換が要ります。
user = msgspec.json.decode(b'{"id":1,"name":"moha","email":"moha@example.com"}', type=User)
print(msgspec.structs.asdict(user))
print(msgspec.to_builtins(user))
{'id': 1, 'name': 'moha', 'email': 'moha@example.com'}
{'id': 1, 'name': 'moha', 'email': 'moha@example.com'}
両者は似ていますが役割が違います。structs.asdict は Struct 専用で全フィールドをそのまま辞書化。to_builtins は datetime や enum を含む任意の型を JSON 互換の組み込み型へ落とすので、公式 Usage の “Converting to and from Builtin Types” が示すとおり「シリアライズ手前の正規化」に向きます。なお omit_defaults は structs.asdict には効きません。常に全フィールドを返します。
OpenAPIへ橋渡しする
FastAPI のように OpenAPI を吐く文脈では、msgspec.json.schema で型から JSON Schema を生成できます。複数モデルをまとめるときは msgspec.json.schema_components。公式の “JSON Schema” ページが OpenAPI 向けの component 出力を扱っています。スキーマを別管理せず、Struct 定義1つから検証もドキュメントも導けます。
それでもPydanticを残すべきところ
全部を msgspec に寄せる必要はありません。手元では、レスポンスを返す層は msgspec に移しつつ、アプリの設定読み込みは pydantic-settings のまま残しました。理由は単純で、msgspec には設定管理レイヤーが無いからです。
Pydantic を選ぶ基準は3つ。1つ目、環境変数や .env から型付きで設定を読むなら pydantic-settings が手っ取り早い。2つ目、フィールド間にまたがる複雑な検証(「A が真なら B は必須」など)を書くなら、Pydantic の field/model validator のほうが素直に書けます。3つ目、既存ライブラリが BaseModel を前提にしているとき。msgspec への移行は公式ドキュメントに “Porting” セクションがあり段階的に進められますが、エコシステムの広さは今も Pydantic が上です。
判断を一言でいうと
包括的に検証したいなら Pydantic、明示的に速く通したいなら msgspec。公式の言葉を借りれば、前者は「検証は自動かつ包括的であるべき」、後者は「検証は明示的かつ高速であるべき」という思想の違いです。ホットパスだけ msgspec、それ以外は Pydantic、という混在も普通に成立します。
まとめ
- msgspec 0.21.1 は encode/decode と型検証に絞った Rust コアのシリアライザ。手元の5フィールドモデルで decode+検証 約4.1倍、encode 約6.8倍(対 Pydantic 2.13.4)
- 速度差が効くのは大量リストや高頻度メッセージ。数件返すだけのエンドポイントでは体感差は出ない
- インスタンスのメモリは Pydantic v2 と同等(ともに72バイト)。優位は割り当てを減らしたデコード経路にある
- 制約は
Annotated+Meta(ge=...)、レスポンス整形はrename/omit_defaults/ Tagged Unions で書ける - 設定管理・複雑な相互検証・広いエコシステムが要るなら Pydantic を残す。ホットパスだけ移す混在構成でよい

