На написание этой статьи меня сподвигла статья «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

И визуализация усреднённых значений:

График создания объектов пришлось отселить, а запись в атрибут pydantic - подрезать.
График создания объектов пришлось отселить, а запись в атрибут pydantic - подрезать.

Так-так, если вам нужно напечь кучку объектов в коде побеждает датакласс. Если вам нужно что-то обрабатывать, постоянно обновляя и перезаписывая, датакласс не просто побеждает. Он прямо разгромно побеждает. Итого, по результатам взвешивания, датакласс легче и быстрее, что вполне подтверждает интуитивные ожидания. Изменение методики замеров ничего не меняет.

Вместо миллиона взаимодействий с одним атрибутом - взаимодействие с миллионом экземпляров
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)


  1. FFiX
    01.12.2025 11:11

    А какое ваше мнение о msgspec? Рассматривали ли вы его в сравнении с обсуждаемыми тут pydantic и датаклассами?


    1. MnogoNog Автор
      01.12.2025 11:11

      Выглядит очень перспективно, как по мне, но на практике, чтобы прочувствовать в реальности возможности и ограничения - не использовал.


  1. sylotana
    01.12.2025 11:11

    Ну библиотеки для разных целей используются, уже вторая статья где их сталкивают лбом, в чем прикол