На написание этой статьи меня сподвигла статья «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. Объедините их. Поздравляю вас, вы великолепны.
