msgspec vs Pydantic—Python高速シリアライズと型検証の使い分け

msgspec vs Pydantic—Python高速シリアライズと型検証の使い分け | mohablog

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.1Pydantic 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))

実行結果。decodetype= に 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.MetaAnnotated で添える形で表現します。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}'

デフォルトと同じ hostport が消え、変更した 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_defaultsstructs.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 を残す。ホットパスだけ移す混在構成でよい
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次