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.
- Raise the dependency floor: change
pydantic<2topydantic>=1.10.17. This makes "thepydantic.v1namespace within v1" usable. - Apply
bump-pydantic(next chapter). Auto-convert mechanical renames. - Stash imports of unmigrated code: for modules not yet migrated, replace
from pydantic.X import Ywithfrom pydantic.v1.X import Y. - Install v2. Migrated modules use the top-level
pydantic, unmigrated modules usepydantic.v1, and they run side by side. - Crush behavior changes by hand (chapters 3–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.BaseModeland a v2BaseModelwithin 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 v1 | Pydantic 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 = 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 Configitself is deprecated: in v2, usemodel_config = ConfigDict(...). On multiple inheritance,model_configis 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.
| State | Field definition |
|---|---|
Required, None not allowed | f: str |
Required, None allowed | f: Optional[str] |
Omittable, None not allowed, default 'abc' | f: str = 'abc' |
Omittable, None allowed, default None | f: Optional[str] = None |
⚠️ The migration crux: code that wrote
x: Optional[int]in v1 and relied on the implicitNonefails in v2 with a "field is missing (missing)" error.Anyis the same and doesn't have an implicitNonedefault in v2. At migration, enumerate allOptionalfields and explicitly add= Noneto those you want omittable. You can mechanically pick these up with search, butbump-pydanticcan'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: float → int 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 feature | Handling in v2 | Response |
|---|---|---|
from pydantic import BaseSettings | moved to a separate package | pip install pydantic-settings (dedicated guide) |
__root__ field | abolished | use RootModel[...] |
class Config: json_encoders | deprecated | @field_serializer / @model_serializer |
orm_mode / GetterDict | abolished | from_attributes=True |
Constrained* classes like ConstrainedInt | deleted | Annotated[int, Field(ge=0)] |
pydantic.generics.GenericModel | deleted | class M(BaseModel, Generic[T]) |
Extra enum | deleted | string literal of ConfigDict(extra="forbid") |
Field(const=...) | deleted | use a Literal[...] type |
Field(min_items/max_items) | deleted | min_length / max_length |
Color / PaymentCardNumber | to a separate package | 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
⚠️ 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, writelist[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
@classmethodand specifymode=(before/after/wrap/plain). each_item=Trueis abolished. Per-element validation is via type annotation —tags: list[Annotated[int, Field(ge=0)]].- The
field/configarguments are abolished. Reference viainfo: ValidationInfo(info.data/info.field_name/info.config). always=Trueis abolished. Substitute withField(validate_default=True).@root_validator→@model_validator.mode="after"receives the instance (self), not a dict.allow_reusebecame unnecessary so delete it.@validate_arguments→@validate_callrename.
⚠️
TypeErroris no longer converted toValidationError: v1 wrapped aTypeErrorinside a validator into aValidationError, but v2 doesn't wrap it. A rawTypeErrorsurfaces 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).
- 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.
- Flow the same input through v1 and v2 and take the diff: run the v1 model in the
pydantic.v1namespace and the v2 model at the top level in parallel, and compare the result ofmodel_dump(). Spots where a diff appears are "silently broken" candidates. - Try passing once with
strict=True: spots that relied on the change in type coercion (chapter 4) are visualized as aValidationError. - 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.
- Staged migration coexists v1/v2 in the
pydantic.v1namespace and proceeds per module (mixing is impossible within a model graph). - Mechanical renames are mostly automated with
bump-pydantic(the method/config correspondence tables). Optional[T]becoming required is the biggest pitfall — explicitly add= Noneto those you want omittable.- Changes in type coercion (float→int, number→str, Union smart mode, Rust regex) can be partially restored with
ConfigDict. BaseSettings/__root__/GetterDict/Constrained*/json_encoders/GenericModelwere moved/removed. Replace per the correspondence tables.- Validators respond to
@classmethod+mode=, and the abolition ofeach_item/field/config/always/allow_reuse. - 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.