Пайплайн обработки заказов читает из очереди двести тысяч событий в час, и после очередного релиза график утилизации процессора шагнул вверх ровно на треть. Команда смотрит в диф: вместо рукописных проверок входные структуры теперь проходят через Pydantic, код стал чище, ошибки информативнее, тесты зелёные. Отставание от очереди при всём этом никуда не делось, и теперь вопрос не в стиле, а в числах. Dataclasses и Pydantic решают внешне похожую задачу совсем по-разному: первые генерируют конструктор, второй исполняет проверку типов на каждом поле. Ниже разбор обоих подходов, замеры на ста тысячах записей, честные ограничения каждой стороны и приёмы, которыми большие объёмы JSON протаскивают через сервис без потерь скорости.

Разный замысел инструментов и граница между конструктором и пограничным контролем

Dataclasses из PEP 557 появились в Python 3.7 как синтаксическое средство против рутины. Декоратор собирает по аннотациям поля и генерирует __init__, __repr__, __eq__, избавляя от трёх похожих методов на класс. О проверке типов в замысле нет ничего: аннотации хранятся в __annotations__ и исполняющим кодом не читаются. Pydantic работает из противоположной точки: аннотации для него это контракт, исполняемый в рантайме при каждом создании модели.

Обе записи одного и того же понятия:

from dataclasses import dataclass

@dataclass
class Order:
    id: int
    amount: float
    email: str
from pydantic import BaseModel, EmailStr, Field

class Order(BaseModel):
    id: int
    amount: float = Field(gt=0)
    email: EmailStr

Разница видна при первом же кривом значении. Dataclass молча примет id строкой, amount значением None и email числом, и обман всплывёт вдалеке от места входа от места входа, в момент первого умножения или конкатенации. Pydantic ответит отказом на входе, с отчётом по каждому полю. Различается и сам способ создания. Dataclass принимает позиционные аргументы, Order(1, 99.5, "Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.") легален и компактен в тестах, параметр frozen=True добавляет защищённость: попытка присвоить полю новое значение обрывается исключением FrozenInstanceError, и слот работает как у иммутабельного кортежа. Модель BaseModel строится только по именованным аргументам, что длиннее в коде, но страхует от перепутанных позиций при росте числа полей. Пограничный контроль против веры в аккуратность отправителя, и выбор между ними это выбор о том, где обслуживать чужие ошибки: сразу или глубоко в коде.

Замер проверки ста тысяч записей от json.loads до модели

Числа важнее лозунгов. Тестовый набор из ста тысяч записей, поля целое, дробное и строка с адресом почты, типичный проход выглядит так:

import json
import time
from random import random

records = [
    {"id": i, "amount": random() * 100, "email": f"user{i}@example.com"}
    for i in range(100_000)
]
payload = json.dumps(records)

t0 = time.perf_counter()
raw = json.loads(payload)
print(f"json.loads: {time.perf_counter() - t0:.2f} с")

t0 = time.perf_counter()
orders = [Order.model_validate(r) for r in raw]
print(f"pydantic v2: {time.perf_counter() - t0:.2f} с")

Типичная картина на четырёх ядрах: json.loads укладывается примерно в полсекунды, Pydantic v2 проверяет те же записи за три-четыре секунды, порядка в восемь раз дольше чистого разбора. Pydantic второй версии, выпущенной 30 июня 2023 года, работает поверх ядра pydantic-core, переписанного на Rust, и по замерам авторов ускорил валидацию в пять-пятьдесят раз против первой версии. Для полноты в тот же прогон стоит добавить конструкторы dataclass с самодельным __post_init__: сто тысяч созданий укладываются примерно в одну-полторы секунды, вдвое быстрее Pydantic, но с проверками одного-двух полей, а не полного набора правил. Честный эксперимент всегда сходится к одной таблице: чистый разбор быстрее всех, dataclass посередине, Pydantic медленнее и информативнее всех.

Так что сравнивать стоит не со старым знанием о медленности Pydantic, а с конкретной альтернативой: разбор без проверок быстрее в разы, и это цена, которую нельзя отменить выбором библиотеки, можно только осознанно принять или обойти потоковой стратегией.

Каждая модель это не только чтение, но и выгрузка. model_dump отдаёт словарь, model_dump_json готовую строку, и на границе с очередями или внешними API сериализация входит в тот же бюджет: честный замер включает оба конца пути, иначе половина работы останется вне графика.

Ограничения dataclasses без ручных проверок и цена самодисциплины

Главное ограничение dataclass звучит просто: он ничего не проверяет. Каждое правило придётся написать руками, и стандартное место для них это __post_init__:

@dataclass
class Order:
    id: int
    amount: float

    def __post_init__(self):
        if not isinstance(self.id, int) or isinstance(self.id, bool):
            raise TypeError("id обязан быть целым числом")
        if self.amount <= 0:
            raise ValueError("amount обязан быть положительным")

Проверка isinstance с булевым исключением классика: True это тоже целое по иерархии типов, и без второго условия заказ с id равным True тихо уедет дальше. Подобные детали и есть реальная цена пути dataclasses: правила работают, но пишутся и поддерживаются людьми, каждая новая версия правил требует тестов, и никакого единого формата отчёта об ошибке у набора самодельных проверок нет.

Вторая ловушка это изменяемые значения по умолчанию. Прямое default=[] интерпретатор отвергает с требованием использовать default_factory=list, иначе все экземпляры делили бы один общий список. Сообщение об ошибке на месте, но привычка писать фабрику обязана войти в пальцы:

CODE4 С Python 3.10 доступны slots=True для экономии памяти и kw_only для позиционных полей, и оба параметра на крупных наборах объектов ощутимы: объекты со слотами легче и быстрее по доступу.

Наследование припасло отдельную ловушку: поле без значения по умолчанию не может идти после поля с дефолтом, и при расширении иерархии классов порядок полей приходится выравнивать руками, иначе конструктор падает ошибкой прямо на объявлении. Статическая проверка тут бессильна, правило живёт в опыте автора кода.

Для dataclass есть и компромиссный путь: pydantic.dataclasses.dataclass навешивает валидацию Pydantic на синтаксис dataclass, не требуя перехода на BaseModel. Удобно для постепенной миграции, но по производительности это тот же Pydantic, а не бесплатное усиление стандартной библиотеки.

Ограничения Pydantic от мягкого приведения значений до стоимости контроля

Первое ограничение Pydantic замечают на сюрпризах: по умолчанию он мягко приводит значения. Строка "5" в поле int станет числом, число 12 превратится в строку там, где аннотация str, дробь без знака после запятой читается как дата. Это удобно для веб-форм, где всё приезжает текстом, и опасно для очередей, где строка вместо числа это признак бага у отправителя. Лечится строгим режимом:

from pydantic import ConfigDict

class Order(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")

    id: int
    amount: float

strict запрещает приведение, extra="forbid" закрывает приём лишних полей, без него Pydantic молча выбрасывает непредусмотренные ключи, и опечатка в имени поля у отправителя остаётся невидимой. На пограничных сервисах обе настройки почти всегда правильный дефолт.

Второе ограничение это отчёт об ошибках, он того стоит. Отказ выглядит как единый список по всем полям, с типом и адресом:

ValidationError: 2 validation errors for Order
id
  Input should be a valid integer [type=int_parsing]
amount
  Input should be greater than 0 [type=greater_than]

Одна попытка показывает оба повреждённых поля, в отличие от самодельных проверок, которые падают по одному.

Для массовой проверки существует и более прямой путь: TypeAdapter разбирает строку и проверяет её за один проход, без промежуточного словаря, и именно такой API рекомендуется для больших массивов записей:

CODE7 Правила, зависящие от сочетания полей, получают собственный валидатор уровня модели:

from pydantic import model_validator

class Order(BaseModel):
    id: int
    amount: float

    @model_validator(mode="after")
    def check(self):
        if self.amount > 10_000 and self.id <= 0:
            raise ValueError("крупные заказы требуют явного id")
        return self

В dataclass те же условия уходили бы в __post_init__, и разницы в выразительности почти нет: ограничение Pydantic не в языке правил, а в том, что каждое из них исполняется на каждом создании модели.

Третье ограничение организационное: переход с первой версии на вторую сломал совместимость, .dict() и .json() уступили место model_dump(), и миграция больших проектов прошла через целую эпоху исправлений.

Большие объёмы JSON на orjson и msgspec с потоковой обработкой гигантских файлов

Когда объёмы растут, помогает не смена модели, а смена стратегии. Быстрый разбор: библиотека orjson, написанная на Rust, заменяет json.loads и по замерам авторов обгоняет стандартную библиотеку кратно, экономя секунды на каждом миллионе записей. Ещё быстрее работает msgspec с его Struct: значения проверяются прямо при разборе строки, без промежуточных словарей:

import msgspec

class Order(msgspec.Struct, frozen=True):
    id: int
    amount: float
    email: str

orders = msgspec.json.decode(payload, type=list[Order])

По общественным замерам msgspec на такой задаче тратит единицы секунд на миллион записей, быстрее Pydantic v2 на сопоставимой строгости. Плата знакома: меньше информативности в ошибках, меньше готовых проверок вроде EmailStr, больше самостоятельной работы со сложными правилами.

Для файлов, не влезающих в память, разбор идёт потоком. Библиотека ijson читает огромный JSON по элементам, не строя всё дерево в оперативной памяти:

import ijson

with open("events.json", "rb") as f:
    for record in ijson.items(f, "item"):
        handle(record)

Память второй аргумент помимо скорости: гигабайтный дамп JSON после разбора занимает в объектах Python в два-три раза больше, и удержание такого дерева в процессе процесса оборачивается сборщиком мусора на каждую генерацию. Потоковая обработка держит в памяти один элемент, а не весь файл.

Архитектурно та же логика применима к очередям: читать чанками, проверять и выгружать пакетами, не держать миллион объектов в памяти процесса. Dataclasses и Pydantic одинаково хорошо работают на маленьких порциях, а проигрывают на гигантских, поэтому масштаб проблемы решается раньше выбора библиотеки.

Чек-лист выбора между dataclasses и Pydantic под нагрузку сервиса

Выбор модели под входные структуры проще делать по признакам нагрузки:

  1. значения приходят из недоверенных источников, вебхуки, внешние API, пользовательские формы, и нужен отказ на входе, это работа Pydantic со strict=True и extra="forbid";
  2. структуры создаются внутри процесса, парсятся из источников под контролем команды, dataclasses без проверок быстрее и честнее;
  3. потоки из сотен тысяч записей в час требуют замеров обоих вариантов на реальном payload, решения принимаются по цифрам своего железа, а не по чужим бенчмаркам;
  4. гигантские файлы разбираются потоково через ijson или msgspec, а не грузятся целиком в память;
  5. сложные бизнес-правила, требующие перекрёстных проверок, живут в валидаторах модели, а не рассыпаются по коду;
  6. миграция с Pydantic первой версии планируется отдельной задачей, переименования методов трогают каждый вызов сериализации.

Граница проходит не между хорошей и плохой библиотекой, а между доверенной и недоверенной средой. Внутри процесса dataclasses экономят проценты процессора и тысячи строк аннотаций. На границе с внешним миром Pydantic тратит те же проценты на то, чтобы чужая ошибка не стала внутренним инцидентом. Скорость в обоих случаях возвращается не сменой инструмента, а устройством потока: чанки, быстрые парсеры и строгие места входа. Пайплайн, который не может себе позволить проверку каждого поля, рано или поздно платит за это отладкой, просто в другом отделе графика.