Skip to main content
友田 陽大
Pydantic & type-safe validation
Python
Pydantic
型安全
バリデーション
アーキテクチャ設計

Complete Pydantic v1 → v2 migration guide: bump-pydantic, staged migration, and crushing 'silently breaking' changes

Faithful to the Pydantic official migration guide, with real code and a verification path it explains mechanical renames with bump-pydantic, staged migration in the pydantic.v1 namespace, the correspondence tables for methods/config/validators, and how to crush 'silently breaking' breaking changes such as the abolition of Optional's implicit default, changes in type coercion, and behavior changes in union/regex.

Published
Reading time
10 min read
Author
友田 陽大
Share

Introduction: the real hard part of migration is that it "works"

Pydantic v2 was officially released in 2023, with the validation core pydantic-core rewritten in Rust. Even so, many codebases still run on v1 — legacy systems, libraries whose maintenance stopped, and v1-style code that generative AI tends to output.

When you hear "migration," you may imagine the mechanical work of replacing @validator with @field_validator and .dict() with .model_dump(). This is certainly tedious, but it's not the essential hard part. It can be mostly automated with bump-pydantic described later.

What's truly dangerous is the breaking changes that "work but behave quietly differently." The meaning of Optional[int] changes, the conversion rules from float to int change, the resolution order of Union changes. The code passes without an error, and if tests are thin, the problem manifests only in production, and in a different place at that. This is the point where people bleed in legacy migration.

This article, faithful to the official migration guide, splits migration into 2 layers — "① mechanical renames (automatable)" and "② silently breaking changes (no choice but to crush by hand)" — and explains with emphasis on the latter. For the design of Pydantic v2 itself, see the Pydantic v2 practical guide.


1. Migration strategy: "coexist" in the pydantic.v1 namespace

Migrating a huge codebase all at once is reckless. v2 bundles the entire v1 API under the pydantic.v1 namespace, letting you migrate in stages while coexisting v1 and v2 in the same process.

The procedure the official recommends is roughly the following.

  1. Raise the dependency floor: change pydantic<2 to pydantic>=1.10.17. This makes "the pydantic.v1 namespace within v1" usable.
  2. Apply bump-pydantic (next chapter). Auto-convert mechanical renames.
  3. Stash imports of unmigrated code: for modules not yet migrated, replace from pydantic.X import Y with from pydantic.v1.X import Y.
  4. Install v2. Migrated modules use the top-level pydantic, unmigrated modules use pydantic.v1, and they run side by side.
  5. Crush behavior changes by hand (chapters 3–6).
  6. Enable both mypy plugins (during the migration period).
# 未移行コードは v1 名前空間で動かし続ける
from pydantic.v1 import BaseModel as BaseModelV1

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

⚠️ Don't mix v1 and v2 models: mixing a model inheriting pydantic.v1.BaseModel and a v2 BaseModel within one model graph is not supported (using a v1 model as a v2 generic's type argument, etc., is impossible). Separate cleanly per module and repack at the boundary.


2. Mechanical renames: bulk-convert with bump-pydantic

First clear the part that can be automated. Use the official migration-support tool (beta).

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

bump-pydantic is an "aid," not complete (the official doesn't guarantee completeness either). The behavior changes from chapter 3 onward must be fixed by hand. That said, it mechanically handles the following method/attribute renames.

Pydantic v1Pydantic v2
.dict().model_dump()
.json().model_dump_json()
.parse_obj().model_validate()
.parse_raw().model_validate_json() (parse_raw itself is deprecated)
.copy().model_copy()
.construct().model_construct()
.schema() / .schema_json().model_json_schema()
.from_orm(obj).model_validate(obj) (requires from_attributes=True)
.update_forward_refs().model_rebuild()
__fields__model_fields

Config (Config) keys are also bulk-conversion targets.

Pydantic v1 (class Config)Pydantic 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 itself is deprecated: in v2, use model_config = ConfigDict(...). On multiple inheritance, model_config is merged, and the rightmost base class wins conflicts.


3. Silently breaking ①: Optional[T] is no longer "default None"

This is the biggest and most dangerous change. In v1, x: Optional[int] meant "omittable, default None." In v2, Optional[int] means "allows None but the value is required." The implicit default was abolished.

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 の意味はこれ)

The official table clarifies these four quadrants.

StateField definition
Required, None not allowedf: str
Required, None allowedf: Optional[str]
Omittable, None not allowed, default 'abc'f: str = 'abc'
Omittable, None allowed, default Nonef: Optional[str] = None

⚠️ The migration crux: code that wrote x: Optional[int] in v1 and relied on the implicit None fails in v2 with a "field is missing (missing)" error. Any is the same and doesn't have an implicit None default in v2. At migration, enumerate all Optional fields and explicitly add = None to those you want omittable. You can mechanically pick these up with search, but bump-pydantic can't judge the meaning, so it can't automate it. It's the top thing to crush by hand.


4. Silently breaking ②: the rules of type coercion changed

v2 made type coercion more rigorous and predictable. Some of the convenient "smart conversions" are disabled by default.

H3: floatint only when the fractional part is zero

class Model(BaseModel):
    x: int


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

H3: number → string coercion is off by default

class Model(BaseModel):
    code: str


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

H3: Union is resolved in "smart mode"

v1 tried Union members from left to right. v2's default is smart mode — it chooses the "most naturally fitting" type.

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: the regex engine became Rust-made

v2's pattern uses Rust's regex crate, so lookaround and backreference can't be used.

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

Other changes include the abolition of automatic conversion of "an iterable of pairs → dict" and the normalization of collection subclasses (Counter, etc.) to a plain dict, and so on. For the big picture of type coercion, also see the strict / lax section of the Pydantic v2 practical guide.


5. Silently breaking ③: moved/removed features

Cases where the feature itself was moved or deleted. Some can be noticed with an import error (= still better), but you need to grasp them.

v1 featureHandling in v2Response
from pydantic import BaseSettingsmoved to a separate packagepip install pydantic-settings (dedicated guide)
__root__ fieldabolisheduse RootModel[...]
class Config: json_encodersdeprecated@field_serializer / @model_serializer
orm_mode / GetterDictabolishedfrom_attributes=True
Constrained* classes like ConstrainedIntdeletedAnnotated[int, Field(ge=0)]
pydantic.generics.GenericModeldeletedclass M(BaseModel, Generic[T])
Extra enumdeletedstring literal of ConfigDict(extra="forbid")
Field(const=...)deleteduse a Literal[...] type
Field(min_items/max_items)deletedmin_length / max_length
Color / PaymentCardNumberto a separate packagepydantic-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

⚠️ Constraints don't automatically descend to generic arguments: in v2, writing list[str] = Field(pattern=".*") doesn't apply to each element. To make it bite the elements, write list[Annotated[str, Field(pattern=".*")]]. A constraint written with the v1 feel "silently stops working" — a typical case, so be careful.


6. Migrating validators: changes in signature and behavior

Validators have changes beyond renames.

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

Main changes:

  • Explicitly add @classmethod and specify mode= (before / after / wrap / plain).
  • each_item=True is abolished. Per-element validation is via type annotation — tags: list[Annotated[int, Field(ge=0)]].
  • The field / config arguments are abolished. Reference via info: ValidationInfo (info.data / info.field_name / info.config).
  • always=True is abolished. Substitute with Field(validate_default=True).
  • @root_validator@model_validator. mode="after" receives the instance (self), not a dict.
  • allow_reuse became unnecessary so delete it.
  • @validate_arguments@validate_call rename.

⚠️ TypeError is no longer converted to ValidationError: v1 wrapped a TypeError inside a validator into a ValidationError, but v2 doesn't wrap it. A raw TypeError surfaces on a signature mismatch, etc., so after migration review the validators' calling conventions.


7. Build the verification path first: make migration "provable"

As we've seen, the danger of migration is that it "silently breaks." That's exactly why preparing a verification path before starting the migration is the iron rule (CLAUDE.md's "Verification First" principle).

  1. Write characterization tests first: against the pre-migration v1 code, enrich tests that fix "this output / this error for this input." This becomes the safety net of migration.
  2. Flow the same input through v1 and v2 and take the diff: run the v1 model in the pydantic.v1 namespace and the v2 model at the top level in parallel, and compare the result of model_dump(). Spots where a diff appears are "silently broken" candidates.
  3. Try passing once with strict=True: spots that relied on the change in type coercion (chapter 4) are visualized as a ValidationError.
  4. Enable both mypy plugins and pick up type-level regressions too.
# 移行の差分検出:同じ入力を 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()

For details on the testing strategy, see Pydantic testing strategy.


Conclusion: crush migration by splitting into "mechanical work" and "the gatekeeper of behavior"

The Pydantic v1 → v2 migration becomes foreseeable if you think in 2 layers. Restating the key points of this article.

  1. Staged migration coexists v1/v2 in the pydantic.v1 namespace and proceeds per module (mixing is impossible within a model graph).
  2. Mechanical renames are mostly automated with bump-pydantic (the method/config correspondence tables).
  3. Optional[T] becoming required is the biggest pitfall — explicitly add = None to those you want omittable.
  4. Changes in type coercion (float→int, number→str, Union smart mode, Rust regex) can be partially restored with ConfigDict.
  5. BaseSettings / __root__ / GetterDict / Constrained* / json_encoders / GenericModel were moved/removed. Replace per the correspondence tables.
  6. Validators respond to @classmethod + mode=, and the abolition of each_item/field/config/always/allow_reuse.
  7. As the premise for everything, prepare the verification path (tests, v1/v2 diff, strict, mypy) first.

"It works so it's fine" is the most dangerous judgment in migration. Start after visualizing the behavior changes with tests, and migration becomes not a risk but an investment to gain performance, ecosystem, and type safety.

As an official primary source, I strongly recommend reading through the Migration Guide from this article's viewpoint.


Consulting on Pydantic v1 → v2 migration and legacy type-safety

The author has evolved production Python backends without stopping them, starting with a B2B SaaS that won the Minister of Economy, Trade and Industry Award. Pydantic migration is not a mere version bump but a project of "safely crushing silently-breaking changes while visualizing them with a verification path." I carry out building characterization tests, designing staged migration, detecting behavior diffs, and surrounding type-safety including FastAPI/SQLAlchemy, quickly and at high quality with generative AI. For the Pydantic migration of a production system that can't be stopped, feel free to reach out.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

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.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading