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)

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, то тогда пример опять же корректно создает множество из двух элементов независимо от порядка.

omaxx
19.05.2026 12:13Это хорошо: ошибка ловится сразу, разработчик идёт в документацию и узнаёт про
field(default_factory=...).Проблема в другом.
И в чем же другом проблема? В том, что разработчик не идет в документацию и не узнает про
field(default_factory=...)?
michael108
А нельзя ваше "Итого" запихнуть в Warning компилятора при использовании датаклассов? В принципе, рано или поздно ИИ начнет делать соответствующие проверки, чтобы учесть перечисленные грабли, но до тех пор кожаным мешкам неплохо было бы напоминать, что им может неожиданно прилететь по лбу.