Прямой ответ
Три инструмента решают разные задачи, и выбор — про границу доверия и стоимость. dataclass — «структура с удобством»: автогенерация __init__/__repr__/__eq__ из аннотаций, нулевая рантайм-валидация (аннотации — документация для mypy, не проверка — типизация); для внутренних доменных объектов, DTO между слоями, конфигов из доверенного источника — оптимум (быстро, прозрачно, stdlib). Pydantic — валидация и парсинг недоверенных/внешних данных: приведение типов, ошибки с локацией, сериализация, JSON Schema — граница системы (HTTP-запросы, файлы конфигов, ответы внешних API, сообщения очередей) — FastAPI-валидация построена на нём; цена — зависимость и рантайм-стоимость (v2 на Rust-ядре — в разы быстрее v1, но dataclass всё равно дешевле). TypedDict — типизация обычного dict (ноль рантайма вообще: только подсказки mypy — [PEP 589]) — для структур, которые обязаны остаться словарями (JSON-payload'ы, конфиги-словари, совместимость); валидации нет. Правило одной фразой: данные приходят извне — Pydantic; живут внутри — dataclass; должны остаться dict — TypedDict; смешение легально (pydantic-модель на границе → dataclass внутри — явный маппинг).
dataclass: возможности и границы
Флаги, которые меняют характер: frozen=True — неизменяемость (hash из коробки, защита от случайной мутации — доменные значения/ключи кешей), slots=True (3.10+) — экономия памяти и защита от лишних атрибутов (миллионы объектов — заметно — память), kw_only=True — только именованные аргументы (устойчивость к перестановке полей), order=True — сравнения; field(default_factory=list) — изменяемые дефолты (классический грабель = [] — общий список на все экземпляры — dataclass запрещает его явно). Пост-валидация — __post_init__ (проверки инвариантов «своими руками»: дата_окончания > даты_начала — raise сразу); наследование работает, но порядок полей и дефолты требуют аккуратности (поле без дефолта после дефолтного в родителе — ошибка — композиции часто чище). Границы честности: аннотации не проверяются (тип «int» принимает строку — до первого использования), сериализация — самостоятельно (asdict + свой код/библиотеки), JSON Schema — нет; когда эти потребности появляются системно — пора к Pydantic (или msgspec/attrs как промежуточные варианты).
Pydantic: валидация как сервис
Что даёт сверх dataclass: валидация и приведение ("42" → 42 для int-поля в lax-режиме; strict-режим — без приведений; ошибки — структурированный список «поле/локация/причина» — готовый payload для 422-ответа); вложенность и дженерики (модели внутри моделей, list[Item] — валидируется рекурсивно); сериализация (model_dump/model_dump_json с исключением/трансформацией полей, alias'ы camelCase для API — версии API и контракты дружат); JSON Schema генерация (OpenAPI-документация из моделей — контрактная разработка); валидаторы (@field_validator — кастомные правила декларативно). Цена и ограничения: зависимость (вес/обновления/миграции v1→v2 показали стоимость привязки), рантайм (создание модели дороже dataclass в разы — на горячих путях с миллионами объектов — измерять; v2 сильно сократил разрыв), «валидация везде» размывает границу (модель на каждый внутренний перенос данных — оверхед без пользы — граница доверия одна). Практика: pydantic-модели — на входах/выходах системы (API, конфиги, события), внутри — dataclass-домен (чистый, без зависимостей — тестируется и живёт дольше фреймворков).
TypedDict и комбинированные схемы
TypedDict — аннотация «этот dict имеет такие ключи таких типов»: class Point(TypedDict): x: int; y: int — рантайм-объект остаётся обычным dict (нулевая стоимость, сериализация «уже есть», совместимость с любым кодом, ожидающим dict); total=False — необязательные ключи, Required/NotRequired (3.11+) — по-ключевая опциональность; проверка — только статическая (mypy ловит опечатку ключа; runtime-мусор проходит — доверенные структуры/внутренние контракты). Ниши: payload'ы, которые уходят в JSON как есть (API-ответы из словарей), конфиги-словари, kwargs-структуры, миграционные данные; не для: недоверенного входа (нет валидации — связка «TypedDict + ручная проверка» хуже pydantic), домена с поведением (методы/инварианты — dataclass/класс). Комбинации в реальном проекте: pydantic на HTTP-границе (Request/Response-модели) → маппинг в dataclass-домен (явный, в сервисном слое) → TypedDict для аналитических выгрузок-словарей; одна предметная сущность может иметь три представления по слоям — это не дублирование, а границы ответственности (и семантика исключений та же: ошибки валидации — ValidationError на границе, инварианты домена — свои исключения внутри).
Критерии выбора сводной таблицей
Сводить решение удобно по пяти вопросам. Откуда данные: внешние/недоверенные — Pydantic; внутренние — dataclass; обязаны остаться словарём — TypedDict. Нужна ли валидация в рантайме: да на границе — Pydantic; нет (доверяем, проверяем статически) — dataclass/TypedDict. Нужны ли JSON Schema/OpenAPI: да — Pydantic безальтернативно из трёх. Каков объём объектов в горячем пути: миллионы — dataclass+slots (или msgspec), тысячи — любой. Живёт ли структура долго и меняется ли независимо от фреймворка: домен — dataclass без зависимостей; граничные контракты — Pydantic, их смена синхронна со сменой API. Совпадение ответов по всем пяти вопросам делает выбор очевидным; расхождение — сигнал, что сущность смешивает роли и просит разделения на граничную и доменную модели.
Практический сценарий: рефакторинг «словарей везде»
Проект: все данные ходили dict'ами — опечатки ключей в рантайме, «None пришёл, где ждали список» раз в неделю. Рефакторинг по границам: (1) вход API — pydantic-модели на все эндпоинты (FastAPI дал 422 с локализациями бесплатно; найден класс багов: фронт присылал строки в числовых полях — приведение + strict на критичных полях — валидация); (2) домен — dataclass'ы (Order, OrderLine — frozen для значений, slots для массовых строк; __post_init__ — проверка «сумма строк = итог»); маппинг pydantic→dataclass в сервисном слое явный (20 строк на сущность — цена границы); (3) внутренние словари-конфиги — TypedDict + mypy (словари остались словарями — совместимость, но опечатки ловятся на CI — типизация); (4) замеры: создание 100k доменных объектов — dataclass 0.09s против pydantic-моделей 0.6s (v2) — горячий путь на dataclass не зря; память со slots −35%. Итог: рантайм-ошибки ключей исчезли из прод-логов ( Sentry-топ ошибок до/после — error tracking показал −70% TypeError/KeyError); стоимость — неделя рефакторинга и явные границы, которые новые разработчики читают по типам, а не по догадкам.
FAQ
attrs/msgspec — где они в этой картине? attrs — dataclass «до dataclass» (богаче фичи: валидаторы, конвертеры — но зависимость; после появления dataclass ниша сузилась); msgspec — скорость (структуры + сериализация быстрее pydantic/dataclass+json — высоконагруженные сервисы, где валидация нужна, а pydantic тяжеловат); выбор по потребности: stdlib-достаточность → dataclass, экосистема/схемы → pydantic, экстремальная скорость → msgspec.
Нужна ли валидация внутри домена, если вход уже pydantic?
Да, но другая: вход проверяет форму (типы/диапазоны), домен — инварианты бизнес-логики (переходы статусов, согласованность полей — __post_init__/методы); «вход валиден — объект корректен» верно до первого изменения мутабельного поля (frozen-домен снимает вопрос).
dataclass с frozen=True или pydantic frozen — что для иммутабельности? Оба дают неизменяемость; выбор по остальным потребностям (домен без валидации — dataclass frozen + slots; граничная модель — pydantic с frozen=True); hash-семантика и производительность у dataclass прозрачнее для массовых значений.
Как мигрировать словарь-легаси к моделям постепенно? По границам и по частоте багов: сначала эндпоинты с наибольшим трафиком ошибок (метрика из error tracking), TypedDict как промежуточный шаг там, где dict обязан остаться; «переписать всё сразу» не нужно — каждая переведённая граница даёт эффект немедленно (ошибки ключей исчезают по мере покрытия).
Источники: документация dataclasses (Python), Pydantic 2, PEP 589 — ссылки в шапке.