На написание этой статьи меня сподвигла статья «Pydantic V2: Почему dataclasses вам больше не нужны» и меткий комментарий:
Спасибо за статью, но мне кажется Вы учите детей плохому.
Давайте попробуем разобраться, почему и датаклассы хороши, и pydantic V2 прекрасен, а вместе – они становятся ещё лучше. Или, может быть, устроить смешанное единоборство?

Чего не будет в статье
pydantic V2 вышел в середине 2023 года и примерно в то же время FastApi добавил его поддержку. Так что поглаживать прирост производительности V2 по отношению к V1 спустя два года как V2 в мейнстриме, считаю неспортивным.
Pydantic умер, да здравствует V2!
Взвешивание спортсменов
Первое, необоримое (пока?) преимущество датаклассов – это поддержка slots (причём в отличие от самостоятельного описания класса со слотами – достаточно параметра в декораторе). Это уже делает инстанс датакласса быстрее, легче, веселее. И совершенно недоступно в pydantic.
С другой стороны, сам по себе pydanticV2 имеет ради своих богатых возможностей достаточно развесистые реализации __getattr__, __setattr__
Давайте смотреть разницу:
Простые замеры на элементарные: создание, чтение, запись
import timeit
from dataclasses import dataclass
from functools import partial
from pydantic import BaseModel
REPEAT_INNER = 1000000
REPEAT_OUTER = 1000
@dataclass
class SimpleDataClass:
int_field: int
@dataclass(slots=True)
class SimpleSlotsDataClass:
int_field: int
class SimplePydantic(BaseModel):
int_field: int
def create_one(type_class):
result = None
for i in range(REPEAT_INNER):
result = type_class(int_field=i)
return result
def read_one(type_class):
instance = type_class(int_field=1)
result = 0
for i in range(REPEAT_INNER):
result = instance.int_field
return result
def write_one(type_class):
instance = type_class(int_field=0)
for i in range(REPEAT_INNER):
instance.int_field = i
return instance
for type_action in (create_one, read_one, write_one):
for type_class in (SimpleSlotsDataClass, SimpleDataClass, SimplePydantic):
print(type_action.__name__, type_class.__name__,
timeit.timeit(partial(type_action, type_class), number=REPEAT_OUTER))
Сырой вывод повторения x1000:
create_one SimpleSlotsDataClass 172.53600629998255
create_one SimpleDataClass 201.62099900000612
create_one SimplePydantic 804.2513158000074
read_one SimpleSlotsDataClass 20.80615309998393
read_one SimpleDataClass 20.286767000012333
read_one SimplePydantic 21.695611600007396
write_one SimpleSlotsDataClass 20.024314799986314
write_one SimpleDataClass 20.23543709999649
write_one SimplePydantic 257.7830180999881
И визуализация усреднённых значений:

Так-так, если вам нужно напечь кучку объектов в коде – побеждает датакласс. Если вам нужно что-то обрабатывать, постоянно обновляя и перезаписывая, датакласс не просто побеждает. Он прямо разгромно побеждает. Итого, по результатам взвешивания, – датакласс легче и быстрее, что вполне подтверждает интуитивные ожидания. Изменение методики замеров ничего не меняет.
Вместо миллиона взаимодействий с одним атрибутом - взаимодействие с миллионом экземпляров
import timeit
from dataclasses import dataclass
from functools import partial
from pydantic import BaseModel
REPEAT_INNER = 1000000
REPEAT_OUTER = 1000
@dataclass
class SimpleDataClass:
int_field: int
@dataclass(slots=True)
class SimpleSlotsDataClass:
int_field: int
class SimplePydantic(BaseModel):
int_field: int
SAMPLES = {
type_class.__name__: [type_class(int_field=i) for i in range(REPEAT_INNER)]
for type_class in (SimpleSlotsDataClass, SimpleDataClass, SimplePydantic)
}
def read_line(sample_line):
result = None
for obj in sample_line:
result = obj.int_field
return result
def write_line(sample_line):
obj = None
for i, obj in enumerate(sample_line):
obj.int_field = i
return obj
for type_action in (read_line, write_line):
for type_class in (SimpleSlotsDataClass, SimpleDataClass, SimplePydantic):
print(type_action.__name__, type_class.__name__,
timeit.timeit(partial(type_action, SAMPLES[type_class.__name__]), number=REPEAT_OUTER))
Сырой вывод повторения x1000:
read_line SimpleSlotsDataClass 15.568891600007191
read_line SimpleDataClass 15.789997600018978
read_line SimplePydantic 21.712664100021357
write_line SimpleSlotsDataClass 30.321490600006655
write_line SimpleDataClass 30.777653700002702
write_line SimplePydantic 250.16776399998344
И визуализация усреднённых значений:

Битва взглядов

Да, конечно, стремясь к эффективной реализации, pydantic в своих инструментах активно использует и датаклассы и слоты. Но всё это меркнет по сравнению со встречным упрёком.
Pydantic избавляет от кучи бойлерплейта, и позволяет в декларативном стиле прописать не просто структуру и дефолты для отсутствующих значений, но и гибко настроить и валидацию и сериализацию и вообще красоту. Так что тут датаклассам возразить нечего – «ты конечно быстрый, но что толку, если ты ничего не делаешь»?
Первый раунд, разминка.
Тем не менее, для разминки можно подобрать щадящие условия для соперников.
Ведь уступив «в чистом коде» датаклассам pydantiс очень хорош в нише именно обработки внешних данных. Если мы можем доверять своему коду и данным им порождённым (ведь он покрыт тестами "ведь_правда.jpg"), то со враждебного внешнего мира чего только не залетит.
С одной стороны пропустим антипаттерн «получили словарики, сделал modle_validate, вернул обратно через model_dump в словарик, и отправил „отвалидированные“ данные дальше в спагетти‑код»
С другой стороны, откажемся от model_validate из двух более утилитарных соображений:
Ну это опять будут соревнования кто быстрее создался.
В общем виде из внешней среды к нам приходят байты.
Таким образом, поставим чуть менее синтетическую задачу построения объекта из байтов. И если pydantic имеет из коробки классметод model_validate_json то для датаклассов кто-то другой должен спарсить байты в словари, а словари уже распаковывать в датаклассы.
Создание миллиона экземпляров из байтов
import json
import timeit
from functools import partial
import orjson
from dataclasses import dataclass
from pydantic import BaseModel
REPEAT_INNER = 1000000
REPEAT_OUTER = 1000
@dataclass(slots=True)
class DataClassSlotOrjson:
field_int: int
field_float: float
field_str: str
@classmethod
def model_validate_json(cls, data):
return cls(**orjson.loads(data))
@dataclass
class DataClassOrjson:
field_int: int
field_float: float
field_str: str
@classmethod
def model_validate_json(cls, data):
return cls(**orjson.loads(data))
@dataclass(slots=True)
class DataClassSlotJson(DataClassSlotOrjson):
@classmethod
def model_validate_json(cls, data):
return cls(**json.loads(data))
@dataclass(slots=True)
class DataClassJson(DataClassOrjson):
@classmethod
def model_validate_json(cls, data):
return cls(**json.loads(data))
class PydanticModel(BaseModel):
field_int: int
field_float: float
field_str: str
DATA = b'{"field_int":444222,"field_float":444.222,"field_str":"pydantic vs dataclasses"}'
def parse_data(type_class):
result = None
for i in range(REPEAT_INNER):
result = type_class.model_validate_json(DATA)
return result
for type_class in (DataClassSlotOrjson, DataClassOrjson, DataClassSlotJson, DataClassJson, PydanticModel):
print(type_class.__name__, timeit.timeit(partial(parse_data, type_class), number=REPEAT_OUTER))Сырой вывод повторения x1000:
DataClassSlotOrjson 680.2105407000054
DataClassOrjson 691.4193583999877
DataClassSlotJson 2326.064967699989
DataClassJson 2387.0347478999756
PydanticModel 1229.4967813999974
И визуализация усреднённых значений:

Итак, давайте присмотримся, со всеми валидациями и оверхедом, pydantic создаст свои экземпляры чуть ли не вдвое быстрее, чем если бы собирать «быстрые» датаклассы, распарсив их из json-байтов нативным инструментом.
Конечно, исправить ситуацию можно, если байты в словари приводить сторонним инструментом, (например orjson), – и тогда можно напротив, сделать всё быстрее pydantic'a.
Впрочем, pydantic-то это всё делает «под ключ», и на этот раз оверхэд из валидаторов – отнюдь не лишний (кто там и что прислал в байтах?), так что этот раунд однозначно за ним.
Второй раунд, клинч.
Если мы вспомним разминочный первый раунд, то можем обратить внимание на то что предлагаемые структуры "плоские", состоящие только из элементарных типов. Но на практике можно ожидать, что одни объекты состоят из других и вложенность эта может быть достаточно сложной.
Тут уж нативным парсингом и распаковкой в __init__() не обойдёшься. Pydantic настолько техничнее датаклассов, что соперник просто-таки отлетает в нокдаун.
Третий раунд, партер.
На что тогда можно вообще надеяться датаклассам в этой борьбе? А вот на что:
Вместо вложенных моделей используйте
TypedDictдля определения структуры данных.TypedDict работает примерно в 2,5 раза быстрее, чем вложенные модели
Pydantic поддерживает в качестве вложенных моделей не только другие BaseMoidel, но и TypedDict, NamedTuple, и датаклассы, да. Поэтому следующее сравнение на парсинг сложного, разветвлённого и многоуровневого json'а в виде байтовой строки будет комплексным:
Пришлось парсить не миллион экземпляров, а 100 k.
import timeit
from functools import partial
from dataclasses import dataclass
from pydantic import BaseModel, TypeAdapter
REPEAT_INNER = 100000
REPEAT_OUTER = 1000
@dataclass(slots=True)
class DataClassSlotValues:
field_int: int
field_float: float
field_str: str
@dataclass(slots=True)
class DataClassSlotMiddle:
first_values: DataClassSlotValues
dict_values: dict[str, DataClassSlotValues]
list_values: list[DataClassSlotValues]
@dataclass(slots=True)
class DataClassSlotContainer:
first_middle: DataClassSlotMiddle
middle_middle: DataClassSlotMiddle
last_middle: DataClassSlotMiddle
@dataclass
class DataClassValues:
field_int: int
field_float: float
field_str: str
@dataclass
class DataClassMiddle:
first_values: DataClassValues
dict_values: dict[str, DataClassSlotValues]
list_values: list[DataClassSlotValues]
@dataclass
class DataClassContainer:
first_middle: DataClassMiddle
middle_middle: DataClassMiddle
last_middle: DataClassMiddle
class PydanticValues(BaseModel):
field_int: int
field_float: float
field_str: str
class PydanticMiddle(BaseModel):
first_values: PydanticValues
dict_values: dict[str, PydanticValues]
list_values: list[PydanticValues]
class PydanticContainer(BaseModel):
first_middle: PydanticMiddle
middle_middle: PydanticMiddle
last_middle: PydanticMiddle
class PydanticContainerSlotDataclass(BaseModel):
first_middle: DataClassSlotMiddle
middle_middle: DataClassSlotMiddle
last_middle: DataClassSlotMiddle
class PydanticContainerDataclass(BaseModel):
first_middle: DataClassMiddle
middle_middle: DataClassMiddle
last_middle: DataClassMiddle
DataClassAdapter = TypeAdapter(DataClassContainer)
DataClassSlotAdapter = TypeAdapter(DataClassSlotContainer)
DATA = b'{"first_middle":{"first_values":{"field_int":1,"field_float":1.1,"field_str":"first_main"},"dict_values":{"a":{"field_int":2,"field_float":2.2,"field_str":"dict_a"},"b":{"field_int":3,"field_float":3.3,"field_str":"dict_b"},"c":{"field_int":4,"field_float":4.4,"field_str":"dict_c"}},"list_values":[{"field_int":5,"field_float":5.5,"field_str":"list_1"},{"field_int":6,"field_float":6.6,"field_str":"list_2"},{"field_int":7,"field_float":7.7,"field_str":"list_3"}]},"middle_middle":{"first_values":{"field_int":8,"field_float":8.8,"field_str":"middle_main"},"dict_values":{"d":{"field_int":9,"field_float":9.9,"field_str":"dict_d"},"e":{"field_int":10,"field_float":10.1,"field_str":"dict_e"},"f":{"field_int":11,"field_float":11.2,"field_str":"dict_f"}},"list_values":[{"field_int":12,"field_float":12.3,"field_str":"list_4"},{"field_int":13,"field_float":13.4,"field_str":"list_5"},{"field_int":14,"field_float":14.5,"field_str":"list_6"}]},"last_middle":{"first_values":{"field_int":15,"field_float":15.6,"field_str":"last_main"},"dict_values":{"g":{"field_int":16,"field_float":16.7,"field_str":"dict_g"},"h":{"field_int":17,"field_float":17.8,"field_str":"dict_h"},"i":{"field_int":18,"field_float":18.9,"field_str":"dict_i"}},"list_values":[{"field_int":19,"field_float":19.0,"field_str":"list_7"},{"field_int":20,"field_float":20.1,"field_str":"list_8"},{"field_int":21,"field_float":21.2,"field_str":"list_9"}]}}'
def parse_data(type_class):
try:
call_ = type_class.model_validate_json
except AttributeError:
call_ = type_class.validate_json
result = None
for i in range(REPEAT_INNER):
result = call_(DATA)
return result
for type_class in (PydanticContainer, PydanticContainerSlotDataclass, PydanticContainerDataclass, DataClassAdapter,
DataClassSlotAdapter):
print(type_class.__name__, timeit.timeit(partial(parse_data, type_class), number=REPEAT_OUTER))
Сырой вывод повторения x1000:
PydanticContainer 1808.8842666999553
PydanticContainerSlotDataclass 1678.9650175000424
PydanticContainerDataclass 1652.5461175999953
DataClassAdapter 1629.875241199974
DataClassSlotAdapter 1634.5691842000233
И визуализация усреднённых значений:

Итак, что мы сделали?
Описали сложную структуру аналогичным образом на датаклассах и в моделях pydantic. А потом, в одном варианте корневую модель pydantic оставили как есть, в другом настроили парсить вложенные dataclass-модели. Кроме того, раз уж pydantic позволяет сделать это - обошлись и вовсе без корневой pydantic-модели, воспользовавшись таким инструментом pydantic, как TypeAdapter
И таким образом мы видим, что:
pydantic прекрасно парсит байтовые данные не только лишь в свои прекрасные модели
это происходит быстрее когда нужно спарсить не BaseModel, а более простые типы. Буквально – чем меньше pydantic'a в pydantic – тем быстрее.
(И при всём при этом не упускаем: даже если модель данных описана в датаклассах – парсит-то всё ещё pydantic)
И наконец, контринтуитивный вывод – во всём более быстрые датаклассы со слотами, в случае создания объектов из байтового представления при помощи pydantic внезапно оказываются немного медленнее чем датаклассы без слотов. (Но прочие бонусы остаются, да)
Выведение выводов
Не сказал бы, что можно так просто взять и определить победителя.

pydantic be like
Pydantic - это более мощный инструмент, чем просто описания наследников BaseModel. (Возможно, что при помощи validate_call вы сделаете свой fastapi на минималках?). Он из коробки гарантирует мощный механизм для обеспечения строгости типов, там где это необходимо, изящно используя для этого встроенные в язык необязательные подсказки о типе данных. И предоставляет огромный мультитул и места для расширения поведения классов - валидации, сериализации, алиасы, скрытие полей, и многое другое.
kiss* the dataclass
*kiss
Однако, зачастую, необходимо и достаточно обеспечить строгость структуры и типов данных без дополнительных сложных механизмов проверки, без использования всех возможностей pydantic. Что ж, тогда датаклассы – ваш выбор. Особенно, если объёмы большие, а последующие обработки – сложные. В частности это работает и в FastApi, принимаемые и отдаваемые модели – могут быть датаклассами, и прекрасно поучаствуют в генерации json-schem'ы.
Возьмите скорость и лёгкость датаклассов. Возьмите мощный механизм парсинга и валидации данных pydantic. Объедините их. Поздравляю вас, вы великолепны.
Комментарии (3)

sylotana
01.12.2025 11:11Ну библиотеки для разных целей используются, уже вторая статья где их сталкивают лбом, в чем прикол
FFiX
А какое ваше мнение о msgspec? Рассматривали ли вы его в сравнении с обсуждаемыми тут pydantic и датаклассами?
MnogoNog Автор
Выглядит очень перспективно, как по мне, но на практике, чтобы прочувствовать в реальности возможности и ограничения - не использовал.