メインコンテンツへスキップ
友田 陽大
Pydantic・型安全バリデーション
Python
Pydantic
型安全
バリデーション
アーキテクチャ設計

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

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

公開日
読了時間
12分
著者
友田 陽大
シェア

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

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 移行について、お気軽にご相談ください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る