lenec ru

← все посты

Pydantic v2 vs v1: что реально изменилось и как мигрировать

17K

Команда долго сидела на 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_namepopulate_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_validatorbefore (получаешь сырые данные) и 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 это удобно: посчитал производное один раз в модели, и фронт получает готовое значение.

Стратегия миграции, которая у нас сработала

Если у тебя сервис с десятками моделей, миграция «всё разом» обычно превращается в недельный ад. Мы разбили её на этапы.

  1. Обновляем зависимость, но включаем bridge через pydantic.v1. В Pydantic 2 есть пакет pydantic.v1, который даёт старый API. Импорты from pydantic.v1 import BaseModel работают как раньше. Это нужно, если у тебя есть библиотеки, ещё не мигрировавшие на v2.
  2. Прогоняем линтер bump-pydantic (есть официальный от команды Pydantic). Он автоматически переименует методы, заменит декораторы, переведёт Config на ConfigDict. На наших сервисах он закрыл около 80% механической работы.
  3. Прогоняем тесты. Тут вылезают тонкие места: изменения в коэрсинге типов (v2 строже к преобразованиям), Optional без дефолта, разное поведение Union.
  4. Чиним руками то, что осталось. Чаще всего это валидаторы со сложной логикой и кастомные типы.
  5. Когда сервис весь на 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 на реальных данных. И только потом катай в прод.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.