Переход с первой версии на вторую редко проходит так, как обещает чейнджлог. На бумаге всё выглядит аккуратно - переименовать декоратор, поменять конфиг, и готово. На практике первый же запуск тестов выдаёт цепочку красных строк, среди которых попадаются вещи совершенно неочевидные. Где-то валидатор молча перестал срабатывать, где-то поменялся порядок выполнения, где-то ORM-схема отказалась подтягивать объекты из SQLAlchemy. Эта статья - попытка собрать в одном месте всё, что реально ломается при миграции, и показать, как переписать код так, чтобы новая версия не превратилась в источник скрытых багов.
Почему вторая версия это не просто обновление, а смена движка под капотом
Pydantic v2 это полноценный переписанный движок, в котором ядро валидации переехало в Rust через пакет pydantic-core. Прирост скорости в реальных проектах укладывается в диапазон от пятикратного до пятидесятикратного, в зависимости от характера данных. Но скорость тут второстепенный сюжет. Главное - вторая версия гораздо строже относится к приведению типов, сериализации и поведению валидаторов. То, что первая версия молча проглатывала (приведение строки к числу, неизвестные поля в JSON, наследование generic-моделей), теперь выливается в явную ошибку.
Тем, кто работает с FastAPI, выбор уже фактически сделан за них. Свежие релизы фреймворка полностью отказались от поддержки первой версии Pydantic и требуют минимум 2.7. Так что речь идёт не о теоретической возможности обновиться, а о неизбежности.
Декоратор validator больше не работает так, как раньше, и просто переименовать его недостаточно
Самое заметное изменение касается декораторов. Старый @validator формально остался в кодовой базе под флагом deprecated, но компиляция тихо ломается на нюансах. Поведение части аргументов изменилось, какие-то выпилили совсем, какие-то переехали в новый объект info. Вот как выглядел типичный валидатор в первой версии:
from pydantic import BaseModel, validator
class Order(BaseModel):
quantity: int
price: float
discount: float = 0.0
@validator("quantity")
def quantity_must_be_positive(cls, v):
if v <= 0:
raise ValueError("количество должно быть больше нуля")
return v
@validator("discount", always=True)
def discount_in_range(cls, v):
if not 0 <= v <= 1:
raise ValueError("скидка должна быть в диапазоне от 0 до 1")
return v
А вот эквивалент во второй версии. Обратите внимание на @classmethod, который теперь нужен явно, и на отсутствие аргумента always - его роль играет mode и аккуратная работа со значениями по умолчанию:
from pydantic import BaseModel, field_validator
class Order(BaseModel):
quantity: int
price: float
discount: float = 0.0
@field_validator("quantity")
@classmethod
def quantity_must_be_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("количество должно быть больше нуля")
return v
@field_validator("discount")
@classmethod
def discount_in_range(cls, v: float) -> float:
if not 0 <= v <= 1:
raise ValueError("скидка должна быть в диапазоне от 0 до 1")
return v
Различий больше, чем кажется на первый взгляд. Во-первых, порядок декораторов важен - @classmethod должен стоять под @field_validator, иначе валидатор просто не зарегистрируется. Во-вторых, если в первой версии часто опускали аннотации типов, то во второй разработчики Pydantic настойчиво рекомендуют их добавлять, потому что движок умеет на них опираться при дополнительных проверках. В-третьих, аргумент each_item, которым в первой версии валидировали элементы коллекций, исчез полностью.
Куда делся each_item и почему теперь это решается через аннотации
Раньше для валидации каждого элемента списка можно было написать что-то такое:
from typing import List
from pydantic import BaseModel, validator
class Cart(BaseModel):
items: List[int]
@validator("items", each_item=True)
def item_positive(cls, v):
if v <= 0:
raise ValueError("элементы должны быть положительными")
return v
Во второй версии этот синтаксис не работает совсем. Вместо него предлагается выносить валидацию внутрь аннотации типа через Annotated. Получается чище и переиспользуемо:
from typing import Annotated, List
from pydantic import BaseModel, AfterValidator
def must_be_positive(v: int) -> int:
if v <= 0:
raise ValueError("элемент должен быть положительным")
return v
PositiveInt = Annotated[int, AfterValidator(must_be_positive)]
class Cart(BaseModel):
items: List[PositiveInt]
Подход выглядит непривычно для тех, кто привык писать всё в виде декораторов внутри класса. Зато такой PositiveInt теперь можно использовать в десятках моделей без копирования логики, и сама аннотация говорит читающему код, что именно проверяется. Это одно из тех изменений, которые поначалу раздражают, а через пару недель работы воспринимаются как естественные.
Root_validator превратился в model_validator, но с подвохом по порядку выполнения
Замена @root_validator на @model_validator - частая операция при миграции, и у неё есть неочевидный нюанс, на который натыкаются почти все. В первой версии root-валидаторы с pre=True выполнялись в том порядке, в котором они объявлены. Во второй версии при mode="before" они выполняются в обратном порядке. Команда Pydantic не выносила это изменение в основной список breaking changes, что добавило веселья тем, у кого было несколько последовательных валидаторов с зависимостями.
Старый код:
from pydantic import BaseModel, root_validator
class Payment(BaseModel):
amount: float
currency: str
converted_amount: float = 0.0
@root_validator(pre=True)
def fill_defaults(cls, values):
values.setdefault("currency", "USD")
return values
@root_validator(pre=True)
def calculate_converted(cls, values):
if values.get("currency") == "USD":
values["converted_amount"] = values["amount"]
return values
Прямой перенос на новый синтаксис:
from pydantic import BaseModel, model_validator
from typing import Any
class Payment(BaseModel):
amount: float
currency: str
converted_amount: float = 0.0
@model_validator(mode="before")
@classmethod
def fill_defaults(cls, values: Any) -> Any:
if isinstance(values, dict):
values.setdefault("currency", "USD")
return values
@model_validator(mode="before")
@classmethod
def calculate_converted(cls, values: Any) -> Any:
if isinstance(values, dict) and values.get("currency") == "USD":
values["converted_amount"] = values["amount"]
return values
И здесь начинается фокус. Во второй версии calculate_converted выполнится раньше fill_defaults, потому что объявлен ниже. Если первый валидатор зависит от того, что сделал второй, всё перестаёт работать. Решений два - либо поменять валидаторы местами, либо переключиться на mode="after", где порядок объявления соблюдается, но обрабатывать придётся уже инстанс модели, а не словарь.
Есть и другой вариант для @model_validator с mode="after" - он работает с уже собранным объектом, и это часто чище для бизнес-логики:
from pydantic import BaseModel, model_validator
from typing_extensions import Self
class DateRange(BaseModel):
start: int
end: int
@model_validator(mode="after")
def check_dates(self) -> Self:
if self.start > self.end:
raise ValueError("начало диапазона позже конца")
return self
Заметьте, что в режиме after метод больше не classmethod, он работает как обычный экземплярный метод и возвращает Self.
Конфигурация модели переехала из вложенного класса в словарь
Старый класс Config, который жил внутри модели, во второй версии заменяется на атрибут model_config с экземпляром ConfigDict. Это не косметика - имена части параметров поменялись, и старый код может не упасть с ошибкой, а просто перестать применять конфигурацию.
# Первая версия
from pydantic import BaseModel
class UserModel(BaseModel):
id: int
email: str
class Config:
orm_mode = True
allow_population_by_field_name = True
anystr_strip_whitespace = True
# Вторая версия
from pydantic import BaseModel, ConfigDict
class UserModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
str_strip_whitespace=True,
)
id: int
email: str
Самая болезненная замена в этом списке - orm_mode стал from_attributes. Если в проекте используется SQLAlchemy и десятки схем читают данные прямо из ORM-объектов, то забытый параметр приведёт к тому, что Pydantic откажется доставать атрибуты из объектов и попросит словарь. И ошибка эта вылезет не на этапе старта, а в момент, когда в эндпоинт прилетит реальный запрос.
Методы модели сменили имена почти все разом
Если в коде встречаются from_orm, parse_obj, parse_raw, dict, json и schema - все они либо переехали, либо помечены устаревшими. Соответствие такое:
- from_orm стал model_validate, при включённом from_attributes в конфиге;
- parse_obj стал model_validate;
- parse_raw стал model_validate_json;
- dict() стал model_dump();
- json() стал model_dump_json();
- schema() стал model_json_schema().
Старый вызов:
user_data = {"id": 1, "email": "Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. "}
user = UserModel.parse_obj(user_data)
payload = user.dict()
json_payload = user.json()
Новый вариант:
user_data = {"id": 1, "email": "Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. "}
user = UserModel.model_validate(user_data)
payload = user.model_dump()
json_payload = user.model_dump_json()
Старые методы пока работают, но выкидывают предупреждения о deprecation. Откладывать замену не стоит - в третьей мажорной версии их обещают убрать окончательно, и хочется встретить этот момент с чистым кодом, а не с горящей продакшен-сборкой.
Строгая типизация ломает то, что раньше прощалось
Вторая версия гораздо щепетильнее относится к приведению типов. Привычная история, когда в поле int прилетала строка "42" и Pydantic её молча конвертировал, во многих случаях больше не работает в строгом режиме. Поведение настраивается через strict=True на уровне поля или конфига:
from pydantic import BaseModel, ConfigDict, Field
class StrictModel(BaseModel):
model_config = ConfigDict(strict=True)
user_id: int
name: str
При strict=True попытка передать {"user_id": "42"} приведёт к ошибке валидации. Это правильное поведение для боевого API, но если на него переехать резко, упадёт много мест, где раньше всё работало по наитию. Разумнее включать strict точечно - на полях, где приведение типов точно нежелательно, например на идентификаторах или денежных суммах.
Отдельный сюрприз ждёт тех, кто использовал Optional без явного значения по умолчанию:
# Раньше это работало само
from typing import Optional
from pydantic import BaseModel
class Profile(BaseModel):
nickname: Optional[str]
Во второй версии Optional больше не означает автоматический None по умолчанию. Поле станет обязательным, просто допускающим None в качестве значения. Это контринтуитивно, но логично с точки зрения системы типов. Правильный вариант теперь такой:
from typing import Optional
from pydantic import BaseModel
class Profile(BaseModel):
nickname: Optional[str] = None
Хорошая новость - утилита bump-pydantic, о которой ниже, умеет автоматически добавлять = None во все подобные места.
Сериализация подклассов изменилась и это влияет на безопасность
Тонкое, но важное изменение касается того, как сериализуются вложенные поля, в которые подставлены объекты-наследники. В первой версии при дампе модели в JSON попадали все поля наследника, даже если они не были описаны в родительском типе. Во второй версии по умолчанию сериализуются только те поля, которые объявлены в типе поля.
С одной стороны, это закрывает потенциальную дыру - случайная утечка приватных данных через ответ API. С другой - если код опирался на старое поведение и подсовывал расширенные наследники ради дополнительных полей в ответе, то после миграции эти поля просто исчезнут из выдачи. Заметить это можно только пристальным сравнением старых и новых ответов, потому что схема при этом не падает.
Утилита bump-pydantic делает черновую работу, но не всё
Команда Pydantic выпустила официальный кодмод-инструмент bump-pydantic, который автоматически переписывает значительную часть синтаксиса. Запустить его проще простого:
pip install bump-pydantic
bump-pydantic --diff path/to/your/package
bump-pydantic path/to/your/package
Что он умеет автоматически:
- Добавлять None в значения Optional-полей без значения по умолчанию;
- Заменять класс Config на model_config с ConfigDict;
- Переименовывать атрибуты конфигурации (orm_mode на from_attributes и аналогичные);
- Заменять @validator на @field_validator с правильным порядком декораторов;
- Заменять @root_validator на @model_validator;
- Преобразовывать constr, conint, confloat в эквиваленты с Annotated;
- Заменять GetterDict-паттерны и подсказывать, где требуется ручное вмешательство.
Чего он не сделает - не починит обратный порядок выполнения валидаторов в режиме before, не разрулит зависимости между ними, не подскажет, где each_item нужно перенести в Annotated, и не поправит места, где старый код опирался на нестрогое приведение типов. Всё это придётся вычёсывать руками, по тестам, по предупреждениям в логах и по реальным падениям на стейдже.
Стратегия миграции в живом проекте без даунтайма
Резко переключить большую кодовую базу с первой версии на вторую - почти всегда плохая идея. Разумнее работать поэтапно. Pydantic во второй версии поставляется с модулем совместимости pydantic.v1, через который можно импортировать старые объекты и постепенно переписывать модули один за другим:
from pydantic import BaseModel, Field, model_validator
from pydantic import v1 as pydantic_v1
class NewModel(BaseModel):
foo: int
class LegacyModel(pydantic_v1.BaseModel):
bar: int
Такой подход даёт время разобраться с поведенческими отличиями, прогнать нагрузочные тесты на критичных эндпоинтах, замерить новые цифры по времени отклика. Параллельно полезно включить вывод предупреждений deprecation в CI, чтобы видеть, какие участки кода всё ещё держатся за старый синтаксис. Когда новых предупреждений перестаёт появляться при добавлении фич, значит инерция кодовой базы переломлена и можно убирать слой совместимости.
На что обратить внимание перед мержем в main
Несколько практических точек, в которых чаще всего обжигаются команды после переключения веток:
- покрытие тестами должно затрагивать сериализацию ответов API, а не только валидацию входа, потому что незаметная смена поведения при дампе подклассов проявится именно там;
- ручная проверка эндпоинтов, возвращающих данные из SQLAlchemy, обязательна - забытый from_attributes валится не на старте приложения, а на первом боевом запросе;
- если в проекте есть кастомные get_validators, их придётся переписать на get_pydantic_core_schema, и это требует понимания внутреннего устройства pydantic-core;
- бенчмарки до и после полезны хотя бы для самооценки - прирост скорости в реальных нагрузках обычно действительно есть, но его масштаб сильно зависит от структуры данных, и узнать свои цифры важнее, чем верить рекламным.
Миграция на вторую версию это не разовая операция, а серия маленьких аккуратных правок, растянутых на несколько спринтов. Время на неё окупается - и скоростью, и тем, что код перестаёт прятать ошибки за неявным приведением типов. Главное не пытаться перепрыгнуть в один присест, потому что цена за поспешность это неделя ловли странных багов в продакшене, причины которых лежат глубоко внутри валидаторов и проявляются только на определённых данных.