Пайплайн обработки заказов читает из очереди двести тысяч событий в час, и после очередного релиза график утилизации процессора шагнул вверх ровно на треть. Команда смотрит в диф: вместо рукописных проверок входные структуры теперь проходят через 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, "
Замер проверки ста тысяч записей от 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, иначе все экземпляры делили бы один общий список. Сообщение об ошибке на месте, но привычка писать фабрику обязана войти в пальцы: