Прямой ответ
Выбор определяется одним вопросом: приходят ли данные извне и надо ли их проверять. Если да (HTTP-запросы, конфиги, файлы, сообщения очередей) — pydantic: валидация, преобразование типов и понятные ошибки «из коробки». Если данные рождаются внутри программы и доверенны (внутренние структуры, DTO между слоями, параметры алгоритмов) — стандартные dataclasses: ноль зависимостей, нулевая цена на создание, полная предсказуемость. Гибрид — нормальная архитектура: pydantic-модели на границе системы, dataclasses внутри; ошибкой является не выбор одного из двух, а pydantic «везде, потому что удобно» (цена — скорость и неявные преобразования) или dataclasses на границе (цена — ручная валидация и её отсутствие).
Dataclasses: минимум магии
@dataclass — генератор __init__, __repr__, __eq__ по аннотациям полей, часть стандартной библиотеки. Сильные стороны: предсказуемость (ничего не происходит, кроме присваивания), скорость создания (обычный Python-объект), frozen=True для иммутабельности, slots=True для экономии памяти, наследование и методы — обычные. Слабые — по определению: никакой валидации (в поле int можно положить строку), __post_init__ для проверок быстро превращается в лапшу, сериализация — вручную или библиотекой, вложенные модели и преобразования — ваша работа. Типизация аннотациями дружит с mypy (о типизации в целом — статья про mypy на практике).
Pydantic: валидация как профессия
Pydantic v2 (ядро на Rust) делает три вещи, которых нет у dataclasses. Первая — валидация и принуждение типов: строка «42» станет int 42 (или даст ошибку с точным путём к полю — body.items.3.price), дата из ISO-строки станет datetime. Вторая — ошибки как данные: ValidationError со списком проблем машиночитаем — на этом построены полезные 422-ответы API (FastAPI использует pydantic как основу — см. статью про зависимости и валидацию в FastAPI). Третья — сериализация и схема: model_dump()/model_dump_json() и JSON Schema из модели — контракт для документации и клиентов. Цена: зависимость, строгие правила наследования конфигурации, неявные преобразования (которые надо понимать, чтобы не удивляться), и создание моделей тяжелее dataclass — заметно на миллионах объектов.
Критерии выбора по сценариям
Веб-API, конфиги, CLI-вход, внешние файлы → pydantic: непроверенный вход — его прямая специализация; альтернатива — писать валидацию руками, что и дольше, и хуже. Внутренние доменные объекты, сущности предметки с поведением → dataclasses (или обычные классы): валидация уже произошла на границе, лишние преобразования вредны. Высоконагруженные горячие пути (парсинг миллионов записей) → dataclasses/slots=True/namedtuple: pydantic-валидация там измеряется в процессорном времени. Иммутабельные значения → frozen dataclass; в pydantic v2 аналог — frozen=True в model_config, но семантика проще у dataclasses. Модели для ML/анализа — обычно ни то, ни другое (тяжёлые данные живут в pandas/numpy), а на входе и выходе пайплайна — pydantic.
Границы и подводные камни
Pydantic-ловушки: model_config наследуется неочевидно (extra='forbid' легко потерять в подклассе); strict vs lax режимы преобразований дают разное поведение — для строгих контрактов включать strict; Field(default_factory=list) vs Field(default=[]) — та же мина изменяемых дефолтов, что и в обычном Python. Dataclass-ловушки: изменяемые дефолты (лечатся default_factory), порядок полей с дефолтами при наследовании, eq по всем полям (иногда нужен кастомный), отсутствие валидации «молча пропускает» мусор до первого странного бага в глубине кода. Общее: и там и там модели — не место для бизнес-логики на 200 строк; методы допустимы, простыни — сигнал, что нужен сервисный слой.
Миграция и сосуществование
Реальные проекты живут со смешанным парком, и это нормально: pydantic-модель на границе → model_dump()/конструктор → внутренние dataclasses. Конвертация — явный код в одном месте (фабрика from_api(...)), а не «магический» **dict разбросанно по кодовой базе. При миграции pydantic v1 → v2 механика другая (.dict()→.model_dump(), Config-класс → model_config), но стратегия та же: границы первыми, внутренние структуры — когда удобно. И не забывайте, что сериализация — отдельная большая тема: для тяжёлого JSON-трафика есть более быстрые специализированные инструменты — сравнение в статье про сериализацию в Python.
Практический сценарий: граница и ядро в одном сервисе
Покажем гибрид в действии на типовом сервисе заказов. Слой границы — pydantic: OrderCreate(BaseModel) принимает тело запроса (user_id: int, items: list[OrderItem] с Field(min_length=1), discount_code: str | None) — FastAPI валидирует вход и отдаёт 422 с точным путём к полю без единой строчки ручных проверок; OrderOut — модель ответа, из которой внутренние поля вроде себестоимости физически не могут утечь. Ядро — dataclasses: @dataclass(frozen=True) class Order с поведением (total(), apply_discount(policy)) живёт в доменном модуле, который ничего не знает про HTTP и pydantic; превращение — явная фабрика Order.from_create(dto) в одном месте приложения. Что это даёт практически: доменные тесты конструируют заказы мгновенно и без валидационного слоя (dataclass-конструктор — наносекунды против микросекунд pydantic, на десятках тысяч объектов в тестах разница заметна); изменение формы API (переименовать поле в DTO) не трогает домен; и наоборот — новая бизнес-логика не рискует сломать контракт. Точка, где новички ломают схему — начинают «для удобства» передавать OrderCreate глубоко внутрь (домен обрастает знаниями о DTO) или, наоборот, валидировать pydantic'ом каждый внутренний вызов (платят скоростью без нужды). Держите границу в одном месте — и обе библиотеки работают каждая в своей сильной роли.
FAQ
Правда ли, что pydantic медленный? Создание и валидация модели тяжелее dataclass-конструирования, но pydantic v2 на Rust ускорил это в разы относительно v1. «Медленно» становится только на горячих путях с миллионами объектов — и там валидация на границе всё равно нужна один раз, а не в цикле.
Можно ли dataclasses + attrs/marshmallow? Да, связка «dataclasses внутри + marshmallow на границе» рабочая, но это две библиотеки вместо одной и два набора концепций; pydantic закрывает обе роли сам. Выбор в пользу связки оправдан в устоявшихся кодовых базах с marshmallow.
Нужны ли слоты в pydantic?
model_config = ConfigDict(frozen=True) и слоты дают экономию, но главный выигрыш pydantic не в памяти; для компактных горячих структур берите dataclass(slots=True) без валидационной надстройки.
Что для конфигов приложения: pydantic или TOML+dataclass? Если конфиг простой и доверенный — dataclass достаточно. Если он большой, вкладывается, приходит из env и хочется ошибок «поле X должно быть int» — pydantic (pydantic-settings) окупается.
Источники: документация pydantic (главная, conversions) и dataclasses (docs.python.org) — ссылки в шапке.
FAQ
Как быть с ORM-объектами в pydantic-моделях ответа?
Не отдавать ORM-объекты напрямую: DTO-модель с from_attributes=True строится из атрибутов ORM-объекта явным model_validate(order) — поля ответа фиксированы схемой, лишнее не утекает, связь с сессией в сериализацию не тянется.