Pydantic v2 vs v1: что реально изменилось и как мигрировать
Команда долго сидела на Pydantic v1. Модели там работали, валидаторы прижились, никто не торопился что-то менять. А потом FastAPI начал намекать, что v1 — это уже legacy, и однажды ты приходишь утром, а в новом сервисе зависимость уже на v2. Ну и понеслось.
Мы прошли через этот переход на трёх сервисах. Я хочу рассказать, что реально изменилось под капотом, какие места ломаются, и как мигрировать без приключений на проде.
Главная разница: ядро на Rust
Pydantic v2 — это, по сути, новый продукт. Ядро валидации переписали на Rust (проект pydantic-core), а Python-обёртка стала тонкой. Отсюда два следствия.
Первое — скорость. На наших сценариях с тяжёлыми вложенными моделями v2 быстрее v1 примерно в 4–10 раз. На простых моделях разница меньше, но ощутимая. Для сервиса, где вход и выход API — это десятки моделей, прирост заметный без всяких оптимизаций со стороны прикладного кода.
Второе — новые ошибки и новые правила. Часть API v1 либо удалили, либо переименовали. Часть поведения изменилась тонко: код компилируется, тесты проходят, а в проде что-то идёт не так.
Что переименовали и где грабли
Самые частые точки боли при миграции:
BaseModel.dict()→model_dump(). Старый метод оставили как deprecated, но в чистом v2 он уже warning, а в будущем уберут.BaseModel.json()→model_dump_json().BaseModel.parse_obj()→model_validate().BaseModel.parse_raw()→model_validate_json().Configкласс внутри модели →model_config: ConfigDict.@validator→@field_validator,@root_validator→@model_validator.allow_population_by_field_name→populate_by_name.Optional[X]больше не делает поле опциональным. Теперь это просто разрешениеNone. Чтобы поле было опциональным — нужен дефолт.
Последний пункт — самый коварный. В v1 было так:
from typing import Optional
from pydantic import BaseModel
class Item(BaseModel):
name: Optional[str] = None # необязательное
В v2 это всё ещё работает, но если ты по привычке пишешь name: Optional[str] без дефолта — поле становится обязательным, в которое можно положить None. Это разные вещи. На API это вылезает как «запрос без поля валится с 422».
Валидаторы по-новому
В v1 был один декоратор @validator и хитрый @root_validator. В v2 их разделили чётко.
from pydantic import BaseModel, field_validator, model_validator
class Order(BaseModel):
quantity: int
price: float
discount: float = 0.0
@field_validator("quantity")
@classmethod
def quantity_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("quantity must be positive")
return v
@model_validator(mode="after")
def check_discount(self) -> "Order":
if self.discount > self.price:
raise ValueError("discount cannot exceed price")
return self
Главное: @field_validator теперь обязательно classmethod, и есть два режима у @model_validator — before (получаешь сырые данные) и after (получаешь готовый объект и можешь его проверить).
Если тебе нужна сложная логика с зависимостью между полями — почти всегда это @model_validator(mode="after"). В v1 для этого был @root_validator, и он часто работал в режиме «pre», когда поля ещё не валидированы. В v2 разделение явное и предсказуемое.
Конфиг модели
Вместо вложенного класса теперь словарь с типами:
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
str_strip_whitespace=True,
)
id: int
name: str
Названия параметров местами тоже поменялись. orm_mode стал from_attributes. Имена логичнее, но при миграции это лишний поиск-замена.
Computed fields и сериализация
Появилась штатная поддержка вычисляемых полей через @computed_field. Раньше для этого приходилось городить свойства и вручную выкидывать их в dict.
from pydantic import BaseModel, computed_field
class Rectangle(BaseModel):
width: float
height: float
@computed_field
@property
def area(self) -> float:
return self.width * self.height
В model_dump() это поле попадёт автоматически. На API это удобно: посчитал производное один раз в модели, и фронт получает готовое значение.
Стратегия миграции, которая у нас сработала
Если у тебя сервис с десятками моделей, миграция «всё разом» обычно превращается в недельный ад. Мы разбили её на этапы.
- Обновляем зависимость, но включаем bridge через
pydantic.v1. В Pydantic 2 есть пакетpydantic.v1, который даёт старый API. Импортыfrom pydantic.v1 import BaseModelработают как раньше. Это нужно, если у тебя есть библиотеки, ещё не мигрировавшие на v2. - Прогоняем линтер
bump-pydantic(есть официальный от команды Pydantic). Он автоматически переименует методы, заменит декораторы, переведётConfigнаConfigDict. На наших сервисах он закрыл около 80% механической работы. - Прогоняем тесты. Тут вылезают тонкие места: изменения в коэрсинге типов (v2 строже к преобразованиям),
Optionalбез дефолта, разное поведениеUnion. - Чиним руками то, что осталось. Чаще всего это валидаторы со сложной логикой и кастомные типы.
- Когда сервис весь на v2, выпиливаем bridge на
pydantic.v1.
Где будет больно
Перечислю места, где на наших проектах посыпалось больше всего:
- Кастомные типы. В v1 ты писал классический
__get_validators__. В v2 —__get_pydantic_core_schema__, и API другой. Если у тебя есть библиотека внутренних типов — её придётся переписывать. - Сериализация дат и UUID. В v1 они по умолчанию шли как объекты, в v2 — как строки в JSON по ISO. Если фронт ожидал что-то конкретное — проверь.
- Поведение
Union. В v2 по умолчанию режимsmart: pydantic пытается выбрать наиболее подходящий вариант. Иногда он выбирает не тот. Если нужен старый порядок — ставьField(union_mode="left_to_right"). - Алиасы.
Field(alias=...)теперь по умолчанию означает, что поле читается ТОЛЬКО по алиасу. Чтобы принимать и оригинальное имя, и алиас, надоpopulate_by_name=Trueв конфиге.
Стоит ли мигрировать сейчас
Короткий ответ: да. Pydantic v1 уже в режиме maintenance, новые фичи туда не приезжают, а популярные библиотеки одна за другой переходят на v2. Чем дальше, тем больнее будет миграция.
Если у тебя относительно небольшой сервис и активный набор тестов — это работа на пару дней. Большой монолит со сложными моделями и кучей кастомных типов — неделя-две. Но откладывать не имеет смысла: новые версии FastAPI, SQLAlchemy и многих других пакетов уже завязаны на v2.
Главный совет: не смотри на v2 как на «v1 с новыми именами методов». Это другой движок с другим поведением в граничных случаях. Прочитай миграционный гайд, прогони полный набор тестов, потрогай свои API на реальных данных. И только потом катай в прод.