導入:移行の本当の難所は「動いてしまう」こと
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 を同じプロセスで共存させながら段階移行できます。
公式が推奨する手順は、おおむね次のとおりです。
- 依存の下限を上げる:
pydantic<2をpydantic>=1.10.17にする。これで「v1 の中でpydantic.v1名前空間」が使えるようになる。 bump-pydanticを当てる(次章)。機械的リネームを自動変換する。- 未移行コードのインポートを退避する:まだ移行しないモジュールは
from pydantic.X import Yをfrom pydantic.v1.X import Yに置換しておく。 - v2 をインストールする。移行済みモジュールはトップレベルの
pydanticを、未移行モジュールはpydantic.v1を使い、side by side で動く。 - 挙動変更を手で潰す(第3〜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 v1 | Pydantic 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 Config) | Pydantic v2(model_config = ConfigDict(...)) |
|---|---|
orm_mode = True | from_attributes=True |
allow_population_by_field_name | populate_by_name |
anystr_strip_whitespace | str_strip_whitespace |
min_anystr_length / max_anystr_length | str_min_length / str_max_length |
validate_all | validate_default |
schema_extra | json_schema_extra |
keep_untouched | ignored_types |
allow_mutation = False | frozen=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 許容・デフォルト None | f: Optional[str] = None |
⚠️ 移行の急所:v1 で
x: Optional[int]と書き、暗黙のNoneに依存していたコードは、v2 では**「フィールドが足りない(missing)」エラーで落ちます**。Anyも同様で、v2 では暗黙のNoneデフォルトを持ちません。移行時は全Optionalフィールドを洗い出し、省略可能にしたいものには= Noneを明示してください。これは検索で機械的に拾えますが、bump-pydanticは意味を判断できないため自動化できません。手で潰す筆頭です。
4. 黙って壊れる②:型強制(coercion)のルールが変わった
v2 は型強制をより厳密・予測可能にしました。便利だった「賢い変換」のいくつかが、既定で無効になっています。
H3: float → int は小数部がゼロのときだけ
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: ValidationInfo(info.data/info.field_name/info.config)で参照する。always=Trueは廃止。Field(validate_default=True)で代替する。@root_validator→@model_validator。mode="after"は**dict ではなくインスタンス(self)**を受け取る。allow_reuseは不要になったので削除する。@validate_arguments→@validate_callにリネーム。
⚠️
TypeErrorがValidationErrorに変換されなくなった:v1 はバリデータ内のTypeErrorをValidationErrorに包んでいましたが、v2 は包みません。シグネチャ不一致などで生のTypeErrorが表に出るため、移行後はバリデータの呼び出し規約を見直してください。
7. 検証パスを先に作る:移行を「証明可能」にする
ここまで見てきたとおり、移行の危険は「黙って壊れる」ことにあります。だからこそ、移行に着手する前に検証パスを用意するのが鉄則です(CLAUDE.md の「Verification First」原則)。
- 特性テスト(characterization test)を先に書く:移行前の v1 コードに対し、「この入力でこの出力/このエラー」を固定するテストを充実させる。これが移行の安全網になる。
- 同じ入力を v1 と v2 で流して差分を取る:
pydantic.v1名前空間で v1 モデルを、トップレベルで v2 モデルを並走させ、model_dump()の結果を比較する。差分が出た箇所が「黙って壊れた」候補。 strict=Trueで一度通してみる:型強制の変化(第4章)に依存していた箇所がValidationErrorとして可視化される。- 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 層に分けて考えれば見通せます。本記事の要点を再掲します。
- 段階移行は
pydantic.v1名前空間で v1/v2 を共存させ、モジュール単位で進める(混在はモデルグラフ内では不可)。 - 機械的リネームは
bump-pydanticでほぼ自動化(メソッド・設定の対応表)。 Optional[T]の必須化が最大の落とし穴——省略可能にしたいものには= Noneを明示する。- 型強制の変化(float→int、数値→str、Union スマートモード、Rust 正規表現)は
ConfigDictで部分的に復元できる。 BaseSettings/__root__/GetterDict/Constrained*/json_encoders/GenericModelは移動・廃止。対応表どおり置換する。- バリデータは
@classmethod+mode=、each_item/field/config/always/allow_reuseの廃止に対応する。 - すべての前提として、検証パス(テスト・v1/v2 差分・strict・mypy)を先に用意する。
「動くから大丈夫」は移行において最も危険な判断です。挙動の変化をテストで可視化してから着手すれば、移行はリスクではなく、性能・エコシステム・型安全を得るための投資になります。
公式の一次情報として、Migration Guide を本記事の観点で通読することを強くお勧めします。
Pydantic v1 → v2 移行・レガシー型安全化のご相談
筆者は、経済産業大臣賞を受賞した B2B SaaS をはじめ、本番稼働中の Python バックエンドを止めずに進化させてきました。Pydantic の移行は、単なるバージョン上げではなく「黙って壊れる変更を、検証パスで可視化しながら安全に潰す」プロジェクトです。特性テストの整備・段階移行の設計・挙動差分の検出・FastAPI/SQLAlchemy を含む周辺の型安全化を、生成 AI を活用して高速かつ高品質に実施します。停止できない本番システムの Pydantic 移行について、お気軽にご相談ください。