Chuyển đến nội dung chính
友田 陽大
Pydantic & type-safe validation
Python
Pydantic
型安全
バリデーション
アーキテクチャ設計

Pydantic v1 → v2 移行完全ガイド:bump-pydantic・段階移行・「黙って壊れる」変更の潰し方

Pydantic公式マイグレーションガイドに忠実に、bump-pydanticによる機械的リネーム、pydantic.v1名前空間での段階移行、メソッド/設定/バリデータの対応表、そしてOptionalの暗黙デフォルト廃止・型強制の変化・union/regexの挙動変更など『黙って壊れる』破壊的変更の潰し方を、検証パス付きで実コード解説します。

Published
Reading time
12 min read
Author
友田 陽大
Chia sẻ

導入:移行の本当の難所は「動いてしまう」こと

Pydantic v2 は 2023 年に正式リリースされ、バリデーションの中核 pydantic-core が Rust で書き直されました。それでも、いまだに多くのコードベースが v1 で動いています——レガシーシステム、メンテが止まったライブラリ、そして生成 AI が出力しがちな v1 スタイルのコード

「移行」と聞くと、@validator@field_validator に、.dict().model_dump() に置き換える機械的な作業を想像するかもしれません。これは確かに面倒ですが、本質的な難所ではありません。後述する bump-pydantic でほぼ自動化できます。

本当に危険なのは、「動くけれど、挙動が静かに変わる」破壊的変更です。Optional[int] の意味が変わり、float から int への変換ルールが変わり、Union の解決順が変わる。コードはエラーを出さずに通り、テストが薄ければ本番で初めて、しかも別の場所で問題が顕在化する。これがレガシー移行で人が血を流すポイントです。

本記事は 公式マイグレーションガイド に忠実に、移行を 2 層——「①機械的リネーム(自動化できる)」と「②黙って壊れる変更(手で潰すしかない)」——に分けて、後者に重点を置いて解説します。Pydantic v2 そのものの設計は Pydantic v2 実践ガイド を参照してください。


1. 移行戦略:pydantic.v1 名前空間で「共存」させる

巨大なコードベースを一度に移行するのは無謀です。v2 は、v1 の API 全体を pydantic.v1 名前空間の下に同梱しており、v1 と v2 を同じプロセスで共存させながら段階移行できます。

公式が推奨する手順は、おおむね次のとおりです。

  1. 依存の下限を上げるpydantic<2pydantic>=1.10.17 にする。これで「v1 の中で pydantic.v1 名前空間」が使えるようになる。
  2. bump-pydantic を当てる(次章)。機械的リネームを自動変換する。
  3. 未移行コードのインポートを退避する:まだ移行しないモジュールは from pydantic.X import Yfrom pydantic.v1.X import Y に置換しておく。
  4. v2 をインストールする。移行済みモジュールはトップレベルの pydantic を、未移行モジュールは pydantic.v1 を使い、side by side で動く
  5. 挙動変更を手で潰す(第3〜6章)。
  6. mypy プラグインを両方有効化する(移行期)。
# 未移行コードは v1 名前空間で動かし続ける
from pydantic.v1 import BaseModel as BaseModelV1

# 移行済みコードは通常の v2
from pydantic import BaseModel
# pyproject.toml — 移行期は両方のプラグインを有効化
[tool.mypy]
plugins = ["pydantic.mypy", "pydantic.v1.mypy"]

⚠️ v1 と v2 のモデルを混ぜないpydantic.v1.BaseModel を継承したモデルと、v2 の BaseModel一つのモデルグラフ内で混在させることはサポートされません(v2 のジェネリックの型引数に v1 モデルを使う等は不可)。モジュール単位できれいに分離し、境界で詰め替えてください。


2. 機械的リネーム:bump-pydantic で一括変換する

まずは自動化できる部分を片付けます。公式提供の移行支援ツール(ベータ)を使います。

pip install bump-pydantic
bump-pydantic my_package   # パッケージ配下のソースを一括変換

bump-pydantic は「補助」であって完全ではありません(公式も完全性は保証していません)。第3章以降の挙動変更は手で直す必要があります。とはいえ、以下のメソッド・属性のリネームは機械的に処理してくれます。

Pydantic v1Pydantic v2
.dict().model_dump()
.json().model_dump_json()
.parse_obj().model_validate()
.parse_raw().model_validate_json()parse_raw 自体は非推奨)
.copy().model_copy()
.construct().model_construct()
.schema() / .schema_json().model_json_schema()
.from_orm(obj).model_validate(obj)(要 from_attributes=True
.update_forward_refs().model_rebuild()
__fields__model_fields

設定(Config)のキーも一括変換対象です。

Pydantic v1(class ConfigPydantic v2(model_config = ConfigDict(...)
orm_mode = Truefrom_attributes=True
allow_population_by_field_namepopulate_by_name
anystr_strip_whitespacestr_strip_whitespace
min_anystr_length / max_anystr_lengthstr_min_length / str_max_length
validate_allvalidate_default
schema_extrajson_schema_extra
keep_untouchedignored_types
allow_mutation = Falsefrozen=True

💡 class Config 自体が非推奨:v2 では model_config = ConfigDict(...) を使います。多重継承時、model_configマージされ、最も右の基底クラスが衝突を制します。


3. 黙って壊れる①:Optional[T] はもう「デフォルト None」ではない

これが最大かつ最も危険な変更です。v1 では x: Optional[int] は「省略可能で、デフォルトは None」を意味しました。**v2 では Optional[int] は「None を許容するが、値は必須」**になりました。暗黙のデフォルトは廃止されたのです。

from typing import Optional
from pydantic import BaseModel


class User(BaseModel):
    name: str                  # 必須・None 不可
    nickname: Optional[str]    # ⚠️ v2では「必須・None許容」。省略すると missing エラー!
    bio: Optional[str] = None  # ✅ 省略可能・None 許容(v1 の Optional の意味はこれ)

公式の表が、この四象限を明確にしています。

状態フィールド定義
必須・None 不可f: str
必須・None 許容f: Optional[str]
省略可能・None 不可・デフォルト 'abc'f: str = 'abc'
省略可能・None 許容・デフォルト Nonef: Optional[str] = None

⚠️ 移行の急所:v1 で x: Optional[int] と書き、暗黙の None に依存していたコードは、v2 では**「フィールドが足りない(missing)」エラーで落ちます**。Any も同様で、v2 では暗黙の None デフォルトを持ちません。移行時は全 Optional フィールドを洗い出し、省略可能にしたいものには = None を明示してください。これは検索で機械的に拾えますが、bump-pydantic は意味を判断できないため自動化できません。手で潰す筆頭です。


4. 黙って壊れる②:型強制(coercion)のルールが変わった

v2 は型強制をより厳密・予測可能にしました。便利だった「賢い変換」のいくつかが、既定で無効になっています。

H3: floatint は小数部がゼロのときだけ

class Model(BaseModel):
    x: int


Model(x=10.0)   # ✅ OK(小数部が 0)
Model(x=10.2)   # ❌ v2では ValidationError(v1 は 10 に切り捨てていた)

H3: 数値 → 文字列の強制は既定でオフ

class Model(BaseModel):
    code: str


Model(code=123)   # ❌ v2では既定で ValidationError
# 復元したいなら:model_config = ConfigDict(coerce_numbers_to_str=True)

H3: Union は「スマートモード」で解決される

v1 は Union のメンバーを左から順に試しました。v2 の既定はスマートモード——「最も自然に当てはまる」型を選びます。

class Model(BaseModel):
    value: int | str


Model(value="1").value   # v2: "1"(str のまま)/ v1: 1(int に変換されていた)
# 左から順の挙動に戻すなら:value: int | str = Field(union_mode="left_to_right")

H3: 正規表現エンジンが Rust 製に

v2 の pattern は Rust の regex クレートを使うため、先読み(lookaround)・後方参照(backreference)が使えません

# Python の re に依存した正規表現を使うなら、エンジンを戻す
class Model(BaseModel):
    model_config = ConfigDict(regex_engine="python-re")
    code: str = Field(pattern=r"(?=.*\d)\w+")  # 先読みは python-re が必要

その他、「ペアのイテラブル → dict」への自動変換が廃止コレクションのサブクラス(Counter 等)は素の dict などに正規化される、といった変更もあります。型強制の全体像は Pydantic v2 実践ガイド の strict / lax の節も参照してください。


5. 黙って壊れる③:移動・廃止された機能

機能そのものが移動・削除されたケースです。インポートエラーで気づける(=まだマシな)ものもありますが、把握しておく必要があります。

v1 の機能v2 での扱い対応
from pydantic import BaseSettings別パッケージへ移動pip install pydantic-settings専用ガイド
__root__ フィールド廃止RootModel[...] を使う
class Config: json_encoders非推奨@field_serializer / @model_serializer
orm_mode / GetterDict廃止from_attributes=True
ConstrainedInt 等の Constrained* クラス削除Annotated[int, Field(ge=0)]
pydantic.generics.GenericModel削除class M(BaseModel, Generic[T])
Extra enum削除ConfigDict(extra="forbid") の文字列リテラル
Field(const=...)削除Literal[...] 型を使う
Field(min_items/max_items)削除min_length / max_length
Color / PaymentCardNumber別パッケージへpydantic-extra-types
# 例:__root__ → RootModel、Constrained* → Annotated
from typing import Annotated
from pydantic import BaseModel, Field, RootModel

Tags = RootModel[list[str]]                  # was: class Tags(BaseModel): __root__: list[str]
PositiveInt = Annotated[int, Field(ge=0)]    # was: class PositiveInt(ConstrainedInt): ge = 0

⚠️ 制約はジェネリック引数に自動で降りない:v2 では list[str] = Field(pattern=".*") と書いても各要素には適用されません。要素に効かせるには list[Annotated[str, Field(pattern=".*")]] と書きます。v1 の感覚で書いた制約が「無言で効かなくなる」典型なので注意してください。


6. バリデータの移行:シグネチャと挙動の変化

バリデータはリネーム以上の変化があります。

from typing import Any
from typing import Self
from pydantic import BaseModel, ValidationInfo, field_validator, model_validator


class SignupForm(BaseModel):
    email: str
    password: str
    password_repeat: str
    tags: list[int]

    # v1: @validator("email")  →  v2: @field_validator + @classmethod + mode
    @field_validator("email", mode="after")
    @classmethod
    def normalize_email(cls, value: str, info: ValidationInfo) -> str:
        # v1 の field/config 引数は廃止。info.field_name / info.config で参照する
        return value.strip().lower()

    # v1: @root_validator  →  v2: @model_validator。mode="after" は self を受け取る
    @model_validator(mode="after")
    def check_passwords(self) -> Self:
        if self.password != self.password_repeat:
            raise ValueError("パスワードが一致しません")
        return self

主な変更点:

  • @classmethod を明示し、mode=before / after / wrap / plain)を指定する。
  • each_item=True は廃止。要素ごとの検証は型注釈で——tags: list[Annotated[int, Field(ge=0)]]
  • field / config 引数は廃止info: ValidationInfoinfo.data / info.field_name / info.config)で参照する。
  • always=True は廃止Field(validate_default=True) で代替する。
  • @root_validator@model_validatormode="after" は**dict ではなくインスタンス(self)**を受け取る。
  • allow_reuse は不要になったので削除する。
  • @validate_arguments@validate_call にリネーム。

⚠️ TypeErrorValidationError に変換されなくなった:v1 はバリデータ内の TypeErrorValidationError に包んでいましたが、v2 は包みません。シグネチャ不一致などで生の TypeError が表に出るため、移行後はバリデータの呼び出し規約を見直してください。


7. 検証パスを先に作る:移行を「証明可能」にする

ここまで見てきたとおり、移行の危険は「黙って壊れる」ことにあります。だからこそ、移行に着手する前に検証パスを用意するのが鉄則です(CLAUDE.md の「Verification First」原則)。

  1. 特性テスト(characterization test)を先に書く:移行前の v1 コードに対し、「この入力でこの出力/このエラー」を固定するテストを充実させる。これが移行の安全網になる。
  2. 同じ入力を v1 と v2 で流して差分を取るpydantic.v1 名前空間で v1 モデルを、トップレベルで v2 モデルを並走させ、model_dump() の結果を比較する。差分が出た箇所が「黙って壊れた」候補。
  3. strict=True で一度通してみる:型強制の変化(第4章)に依存していた箇所が ValidationError として可視化される。
  4. mypy プラグインを両方有効化し、型レベルの退行も拾う。
# 移行の差分検出:同じ入力を v1 / v2 に流して出力を比較する
from pydantic.v1 import BaseModel as V1Model   # 旧定義
from app.models import User as V2User          # 新定義

def test_dump_parity():
    payload = {"name": "alice", "nickname": "al"}
    # v1 の出力を基準に、v2 が同じ形を返すことを固定する(差異があればここで落ちる)
    assert V2User.model_validate(payload).model_dump() == LegacyUser(**payload).dict()

テスト戦略の詳細は Pydantic テスト戦略 を参照してください。


結論:移行は「機械作業」と「挙動の番人」に分けて潰す

Pydantic v1 → v2 の移行は、2 層に分けて考えれば見通せます。本記事の要点を再掲します。

  1. 段階移行は pydantic.v1 名前空間で v1/v2 を共存させ、モジュール単位で進める(混在はモデルグラフ内では不可)。
  2. 機械的リネームは bump-pydantic でほぼ自動化(メソッド・設定の対応表)。
  3. Optional[T] の必須化が最大の落とし穴——省略可能にしたいものには = None を明示する。
  4. 型強制の変化(float→int、数値→str、Union スマートモード、Rust 正規表現)は ConfigDict で部分的に復元できる。
  5. BaseSettings / __root__ / GetterDict / Constrained* / json_encoders / GenericModel は移動・廃止。対応表どおり置換する。
  6. バリデータ@classmethodmode=each_item/field/config/always/allow_reuse の廃止に対応する。
  7. すべての前提として、検証パス(テスト・v1/v2 差分・strict・mypy)を先に用意する。

「動くから大丈夫」は移行において最も危険な判断です。挙動の変化をテストで可視化してから着手すれば、移行はリスクではなく、性能・エコシステム・型安全を得るための投資になります。

公式の一次情報として、Migration Guide を本記事の観点で通読することを強くお勧めします。


Pydantic v1 → v2 移行・レガシー型安全化のご相談

筆者は、経済産業大臣賞を受賞した B2B SaaS をはじめ、本番稼働中の Python バックエンドを止めずに進化させてきました。Pydantic の移行は、単なるバージョン上げではなく「黙って壊れる変更を、検証パスで可視化しながら安全に潰す」プロジェクトです。特性テストの整備・段階移行の設計・挙動差分の検出・FastAPI/SQLAlchemy を含む周辺の型安全化を、生成 AI を活用して高速かつ高品質に実施します。停止できない本番システムの Pydantic 移行について、お気軽にご相談ください。

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc