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

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

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, Pydantic, 型安全, バリデーション, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/pydantic-v1-to-v2-migration-complete-guide

## 要点

- 移行は2層に分けて考える。機械的リネーム（.dict→.model_dump等）はbump-pydanticでほぼ自動化できるが、本当の risk は『動くが挙動が変わる』サイレントな破壊的変更にある
- 段階移行はpydantic.v1名前空間が鍵。pydantic>=1.10.17に上げてからv2を入れ、未移行モジュールはpydantic.v1で動かしv1/v2を共存させる
- 最大の落とし穴はOptional[T]が必須化したこと。v1の暗黙のNoneデフォルトは廃止され、=Noneを明示しないとmissingエラーになる
- 型強制も変わった：float→intは小数部0のみ、数値→strは既定オフ、Unionはsmartモード、正規表現はRust製でlookaround不可。いずれもConfigDictで部分的に復元できる
- BaseSettings・__root__・GetterDict・Constrained*・json_encoders・GenericModelは移動/廃止。移行は必ず『テストという検証パス』を先に用意してから行う

---

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

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

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

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

本記事は [公式マイグレーションガイド](https://pydantic.dev/docs/validation/latest/get-started/migration/) に忠実に、移行を **2 層**——「①機械的リネーム（自動化できる）」と「②黙って壊れる変更（手で潰すしかない）」——に分けて、後者に重点を置いて解説します。Pydantic v2 そのものの設計は [Pydantic v2 実践ガイド](/blog/pydantic-v2-production-validation-type-safety) を参照してください。

---

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

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

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

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

```python
# 未移行コードは v1 名前空間で動かし続ける
from pydantic.v1 import BaseModel as BaseModelV1

# 移行済みコードは通常の v2
from pydantic import BaseModel
```

```toml
# pyproject.toml — 移行期は両方のプラグインを有効化
[tool.mypy]
plugins = ["pydantic.mypy", "pydantic.v1.mypy"]
```

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

---

## **2. 機械的リネーム：`bump-pydantic` で一括変換する**

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

```bash
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` を許容するが、値は必須」**になりました。暗黙のデフォルトは廃止されたのです。

```python
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` は小数部がゼロのときだけ**

```python
class Model(BaseModel):
    x: int


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

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

```python
class Model(BaseModel):
    code: str


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

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

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

```python
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
# 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 実践ガイド](/blog/pydantic-v2-production-validation-type-safety) の strict / lax の節も参照してください。

---

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

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

| v1 の機能 | v2 での扱い | 対応 |
| --- | --- | --- |
| `from pydantic import BaseSettings` | **別パッケージへ移動** | `pip install pydantic-settings`（[専用ガイド](/blog/pydantic-settings-configuration-management-secrets-guide)） |
| `__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` |

```python
# 例：__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. バリデータの移行：シグネチャと挙動の変化**

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

```python
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」原則）。

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

```python
# 移行の差分検出：同じ入力を 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 テスト戦略](/blog/pydantic-testing-polyfactory-hypothesis-strategy-guide) を参照してください。

---

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

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. **バリデータ**は `@classmethod` ＋ `mode=`、`each_item`/`field`/`config`/`always`/`allow_reuse` の廃止に対応する。
7. すべての前提として、**検証パス（テスト・v1/v2 差分・strict・mypy）を先に用意**する。

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

公式の一次情報として、[Migration Guide](https://pydantic.dev/docs/validation/latest/get-started/migration/) を本記事の観点で通読することを強くお勧めします。

---

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

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