Dataclasses появились в Python 3.7 и быстро стали стандартом: меньше бойлерплейта, чем у обычных классов, проще, чем attrs, и не требуют зависимостей. Выглядят настолько просто, что кажется, что ломаться там нечему. Но у них есть три ловушки, которые не видны при написании.

Ошибка 1: mutable default в полях

Вот этот код:

from dataclasses import dataclass

@dataclass
class UserConfig:
    name: str
    permissions: list[str] = []

...не компилируется. Python выдаёт ValueError: mutable default value и отказывается создавать класс. Это хорошо: ошибка ловится сразу, разработчик идёт в документацию и узнаёт про field(default_factory=...).

Проблема в другом. Часто разработчик «исправляет» ошибку вот так:

@dataclass
class UserConfig:
    name: str
    permissions: list[str] = None

    def __post_init__(self):
        if self.permissions is None:
            self.permissions = []

Формально работает, но permissions теперь Optional[list[str]], хотя по смыслу это всегда список. Mypy будет ругаться (или молчать с type: ignore), и каждое место использования будет либо проверять на None, либо игнорировать тип. Через полгода кто-то передаст None явно, думая, что это допустимо, и получит баг.

Правильный способ:

from dataclasses import dataclass, field

@dataclass
class UserConfig:
    name: str
    permissions: list[str] = field(default_factory=list)

default_factory вызывается при каждом создании экземпляра. Каждый UserConfig получает свой пустой список. Тип всегда list[str], никакого Optional, mypy доволен.

Для более сложных значений по умолчанию — lambda:

@dataclass
class PipelineConfig:
    name: str
    steps: list[str] = field(default_factory=lambda: ["validate", "transform", "load"])
    metadata: dict[str, str] = field(default_factory=dict)

Каждый экземпляр получает свою копию steps и свой пустой dict. Мутация одного экземпляра не затрагивает другие.

Казалось бы, мелочь. Но часто бывает так, что один экземпляр UserConfig мутировал shared-список permissions, и все остальные экземпляры (созданные до исправления, когда default был списком, а не factory) внезапно получали чужие permissions. Р

Ошибка 2: frozen=True не делает объект неизменяемым

Многие разработчики думают, что frozen=True делает dataclass иммутабельным. Это не так.

@dataclass(frozen=True)
class Report:
    title: str
    tags: list[str] = field(default_factory=list)

report = Report(title="Q1", tags=["finance"])

frozen=True запрещает присваивание в поля. report.title = "Q2" вызовет FrozenInstanceError. Пока всё логично. Но вот это работает без ошибок:

report.tags.append("urgent")
print(report.tags)  # ['finance', 'urgent'] — объект изменился!

frozen=True запрещает setattr (переназначение поля), но не запрещает мутацию содержимого поля. report.tags = new_list упадёт. report.tags.append(item) пройдёт. Python не может запретить мутацию произвольного объекта, потому что не знает, какие его методы меняют состояние, а какие нет.

Frozen-датаклассы часто используют как ключи словарей и элементы множеств, потому что frozen=True генерирует hash. И вот что получается:

cache = {}
key = Report(title="Q1", tags=["finance"])
cache[key] = "some result"

key.tags.append("urgent")  # мутируем содержимое
print(cache[key])           # KeyError! Хеш изменился, ключ потерян.

Хеш объекта вычисляется при первом обращении и зависит от содержимого полей. Когда вы мутируете список внутри frozen-объекта, хеш меняется, и словарь не может найти ключ, потому что ищет по новому хешу в бакете, куда объект попал по старому.

Если нужна настоящая иммутабельность, используйте иммутабельные типы для всех полей:

@dataclass(frozen=True)
class Report:
    title: str
    tags: tuple[str, ...]  # tuple вместо list

report = Report(title="Q1", tags=("finance",))
# report.tags.append("urgent")  # AttributeError: tuple has no append

Tuple, frozenset, str — иммутабельные. Если все поля frozen-датакласса используют такие типы, объект действительно неизменяемый. Если хотя бы одно поле мутабельное, frozen защищает только от переназначения, но не от изменения содержимого.

Правило простое: frozen-датакласс, который используется как ключ или элемент множества, должен содержать только хешируемые и иммутабельные поля. Если нужен список, оберните в tuple. Если нужен set, используйте frozenset. Если нужен dict... ну, тут сложнее, но types.MappingProxyType или просто отказ от dict в frozen-классе.

Ошибка 3: наследование ломает сравнение

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

@dataclass
class Animal:
    name: str
    weight: float

@dataclass
class Dog(Animal):
    breed: str

animal = Animal(name="Rex", weight=30.0)
dog = Dog(name="Rex", weight=30.0, breed="Labrador")

print(animal == dog)  # True
print(dog == animal)  # False

animal == dog вызывает Animal.__eq__, который сравнивает только поля Animal: name и weight. Они совпадают. Про breed Animal ничего не знает, поэтому не проверяет. Результат: True.

dog == animal вызывает Dog.__eq__, который сравнивает все три поля: name, weight, breed. У animal нет breed, Dog.eq возвращает NotImplemented, Python переворачивает и пробует Animal.eq, который опять сравнивает только name и weight. Результат: False.

a == b и b == a дают разные результаты. Это нарушает контракт равенства (симметричность), и последствия проявляются в самых неожиданных местах:

animals = {animal, dog}
print(len(animals))  # 1 или 2? Зависит от порядка вставки.

Set хеширует объекты и проверяет равенство при коллизии. Если animal добавлен первым, а dog при вставке сравнивается с ним через animal == dog (True), set решит, что это дубликат, и не добавит. Если dog первый, а animal сравнивается через dog == animal (False), оба останутся.

Исправить можно тремя способами. Первый — проверять тип в eq:

@dataclass
class Animal:
    name: str
    weight: float

    def __eq__(self, other):
        if type(other) is not type(self):
            return NotImplemented
        return (self.name, self.weight) == (other.name, other.weight)

Теперь Animal("Rex", 30) == Dog("Rex", 30, "Lab") вернёт NotImplemented с обеих сторон, и Python вернёт False. Симметрично.

Второй — использовать eq=False на родителе и генерировать eq только на дочернем классе. Третий — не наследовать датаклассы друг от друга и использовать композицию:

@dataclass
class AnimalInfo:
    name: str
    weight: float

@dataclass
class Dog:
    info: AnimalInfo
    breed: str

Третий вариант самый безопасный, потому что не создаёт проблем с eq, hash и порядком полей. Наследование датаклассов работает, но требует внимания к деталям, которые очень легко пропустить.

Ловушка с порядком полей при наследовании

Ещё одна проблема, которая связана с наследованием, но не с равенством:

@dataclass
class Base:
    name: str
    value: int = 0  # поле с дефолтом

@dataclass
class Child(Base):
    label: str  # поле без дефолта — TypeError!

Python объединяет поля в порядке MRO: name, value (с дефолтом), label (без дефолта). Поля без дефолта после полей с дефолтом запрещены (как в обычных функциях). Решение для Python 3.10+:

@dataclass
class Child(Base):
    label: str = field(kw_only=True)

child = Child(name="test", label="important")  # value=0 по умолчанию

kw_only=True делает поле keyword-only аргументом, и оно не участвует в позиционном порядке.

Итого

Три пункта, которые стоит проверить перед тем, как dataclass попадёт в прод. Все мутабельные дефолты через field(default_factory=...), никаких = [] и = {} и тем более = None с __post_init__. Если frozen=True, все поля иммутабельных типов, иначе frozen защищает только от переназначения. Если наследуете датаклассы, проверьте eq на симметричность и подумайте, не лучше ли композиция.

Если вы видели другие проблемы с dataclasses, пишите в комментариях. Спасибо, что дочитали.


Такие ошибки редко ломают проект сразу, но хорошо показывают, насколько глубоко вы понимаете Python за пределами синтаксиса.

Если вы уже пишете на Python и хотите системно подтянуть уровень — архитектуру, асинхронность, производительность, типизацию и внутреннее устройство языка — можно начать с вступительного теста на курс «Python-разработчик». Он поможет понять, насколько текущего опыта достаточно для обучения на продвинутом уровне.

Практические разборы от экспертов в разработке ищите в календаре бесплатных демо-уроков.

Комментарии (3)


  1. michael108
    19.05.2026 12:13

    А нельзя ваше "Итого" запихнуть в Warning компилятора при использовании датаклассов? В принципе, рано или поздно ИИ начнет делать соответствующие проверки, чтобы учесть перечисленные грабли, но до тех пор кожаным мешкам неплохо было бы напоминать, что им может неожиданно прилететь по лбу.


  1. osmanpasha
    19.05.2026 12:13

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

    cache = {}
    key = Report(title="Q1", tags=["finance"])
    cache[key] = "some result"
    
    key.tags.append("urgent")  # мутируем содержимое
    print(cache[key])          # KeyError! Хеш изменился, ключ потерян.

    Нет, этот код падает еще на третьей строчке с ошибкой TypeError: unhashable type: ‘list’, т.е. в этом случае никакой ловушки нет, датакласс с мутируемыми полями, пусть даже и frozen, просто не получится положить во множество.

    Ошибка 3: наследование ломает сравнение

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

    Кажется, эта ошибка полностью сгалюцинирована, потому что документация в первой же версии датаклассов (3.7) прямо говорит, что:

     This method compares the class as if it were a tuple of its fields, in order. Both instances in the comparison must be of the identical type.

    Соответственно, ни один из примеров ошбку не воспроизводит. Проверял на 3.8 и 3.12 (3.7, где впервые появились датаклассы, сходу не нашел).

    print(animal == dog)  # True
    print(dog == animal)  # False

    Нет, пример выдает оба раза False, то есть все работает корректно.

    animals = {animal, dog}
    print(len(animals))  # 1 или 2? Зависит от порядка вставки.

    Нет, этот пример падает с ошибкой TypeError: unhashable type: ‘Animal’ на первой строчке. Если же автор забыл указать frozen=True, то тогда пример опять же корректно создает множество из двух элементов независимо от порядка.


  1. omaxx
    19.05.2026 12:13

    Это хорошо: ошибка ловится сразу, разработчик идёт в документацию и узнаёт про field(default_factory=...).

    Проблема в другом. 

    И в чем же другом проблема? В том, что разработчик не идет в документацию и не узнает про field(default_factory=...)?