Dataclass, NamedTuple или обычный класс в Python: что выбрать
Три способа описать структуру данных в Python дают разную цену: изменяемость, память, скорость доступа и совместимость с кодом. Разбираем, где каждый выигрывает, а где ломается.

Вам нужно хранить структуру: точку с координатами, запись о заказе, конфиг. В Python на это есть минимум три варианта — обычный класс, collections.namedtuple (или typing.NamedTuple) и @dataclass. Они выглядят взаимозаменяемыми, но платите вы за них по-разному: изменяемостью, объёмом памяти, скоростью доступа к полям и тем, насколько легко потом код рефакторить. Материал написан под Python 3.10–3.12; в 3.10 у dataclass появился slots=True, это важно для сравнения по памяти.
Угол разбора — критерии выбора, а не устройство каждого механизма изнутри. Сериализацию в JSON, валидацию через pydantic и attrs здесь не рассматриваем: это соседние темы, у них своя цена.
Три инструмента и чем они отличаются на входе
Начнём с того, что каждый из них по факту генерирует за вас. Разница не косметическая: она определяет, что вы получите в проде.
- Обычный класс — вы пишете
__init__сами. Ничего не генерируется: ни сравнение по значению, ни читаемыйrepr. Полный контроль, максимум ручной работы. - NamedTuple — неизменяемый кортеж с именованными полями. Наследует поведение
tuple: индексация, распаковка, хешируемость, сравнение по значению. Изменить поле после создания нельзя. - Dataclass — декоратор генерирует
__init__,__repr__, приeq=True(по умолчанию) —__eq__. По умолчанию изменяемый и обычный объект, не кортеж.
Как выглядит одно и то же
# Python 3.10+
from dataclasses import dataclass
from typing import NamedTuple
# Обычный класс: всё вручную
class PointPlain:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
# NamedTuple: неизменяемый, ведёт себя как кортеж
class PointTuple(NamedTuple):
x: float
y: float
# Dataclass: изменяемый объект, __init__ и __repr__ сгенерированы
@dataclass
class PointData:
x: float
y: floatПроверка разницы в поведении — распаковка и сравнение:
pt = PointTuple(1.0, 2.0)
x, y = pt # распаковка работает — это кортеж
print(pt[0]) # 1.0 — доступ по индексу
pd = PointData(1.0, 2.0)
# x, y = pd # TypeError: dataclass не итерируется по умолчанию
print(pd == PointData(1.0, 2.0)) # True — сравнение по значению есть
pp = PointPlain(1.0, 2.0)
print(pp == PointPlain(1.0, 2.0)) # False — сравнение по ссылке, __eq__ не заданЦена каждого выбора
Здесь ключевое различие, из-за которого выбор вообще имеет значение.
Изменяемость
NamedTuple неизменяем: попытка pt.x = 5 даст AttributeError: can't set attribute. Это плюс, если объект должен быть константой (ключ словаря, элемент множества) и минус, если вы правите поля. У dataclass по умолчанию поля меняются; чтобы заморозить — @dataclass(frozen=True), тогда присваивание бросит FrozenInstanceError.
Хешируемость
NamedTuple хешируем из коробки — его можно класть в set и использовать ключом dict. Изменяемый dataclass не хешируем: __hash__ установлен в None. Хеш появляется при frozen=True (и eq=True). Обычный класс хешируем по id, но тогда два равных по смыслу объекта — разные ключи.
Память
Обычный класс и dataclass по умолчанию держат __dict__ на каждый экземпляр — это лишние байты на объект. NamedTuple __dict__ не имеет. С Python 3.10 dataclass можно объявить с @dataclass(slots=True) — тогда __dict__ тоже нет и потребление приближается к слотовому классу. Порядок величин на миллионах мелких объектов заметен; точные числа зависят от версии интерпретатора и полей, поэтому мерьте на своих данных.
Если вы создаёте миллионы мелких записей и упёрлись в память — это не повод переходить на кортежи вслепую. Сначала измерьте slots-dataclass: он даёт изменяемость и читаемость почти по цене кортежа.
Как замерить память под себя
# tracemalloc — стандартный модуль, ничего ставить не нужно
import tracemalloc
tracemalloc.start()
objects = [PointData(float(i), float(i)) for i in range(1_000_000)]
current, peak = tracemalloc.get_traced_memory()
print(f"текущее: {current / 1024 / 1024:.1f} МБ")
tracemalloc.stop()
# Повторите с PointTuple и с dataclass(slots=True) и сравните числа
# на вашей версии CPython и ваших поляхТаблица сравнения
| Свойство | Обычный класс | NamedTuple | Dataclass |
|---|---|---|---|
| Изменяемость полей | Да | Нет | Да (нет при frozen=True) |
| Сравнение по значению | Пишете сами | Есть | Есть (eq=True по умолчанию) |
| Хешируемость | По id | Есть | Только при frozen=True |
| Распаковка / индексация | Нет | Да | Нет |
| __dict__ на экземпляр | Есть (нет при __slots__) | Нет | Есть (нет при slots=True, 3.10+) |
| Значения по умолчанию | Вручную | Да | Да, с оговоркой про mutable |
| Методы и наследование | Свободно | Ограниченно | Свободно |
Когда что выбирать
Критерий простой: сначала изменяемость, потом поведение, потом память.
- NamedTuple — данные неизменяемы по смыслу, нужны распаковка, ключи словаря или элементы множества, совместимость со старым кодом, ждущим кортеж. Пример: координата, диапазон, результат функции, который распаковывают вызывающие.
- Dataclass — значение по умолчанию для новых структур. Поля меняются, нужен читаемый
reprи сравнение, планируете методы и наследование. Нужна неизменяемость плюс хеш — добавляетеfrozen=True. Упёрлись в память —slots=True. - Обычный класс — сложная логика в
__init__, нестандартное сравнение или хеширование, тонкая настройка через дескрипторы иproperty. Если генерируемое поведение мешает, а не помогает.
Что ломается на практике
Три ошибки встречаются чаще всего.
- Изменяемое значение по умолчанию в dataclass.
@dataclassзапрещаетitems: list = []и бросаетValueError: mutable default ... is not allowed. Правильно —field(default_factory=list):from dataclasses import dataclass, field @dataclass class Cart: items: list = field(default_factory=list) # новый список на каждый экземпляр - slots=True и значения по умолчанию до 3.10. Комбинация слотов с дефолтами вручную конфликтует, потому что дескриптор слота занимает имя класса. В dataclass с
slots=True(3.10+) это обрабатывается корректно, но самописный__slots__вместе с дефолтами на уровне класса дастValueError. - NamedTuple воспринимается как кортеж там, где вы этого не ждёте. Он проходит
isinstance(x, tuple)как True и распаковывается. Код, который сериализует «все кортежи как массивы», превратит вашу именованную запись в JSON-массив, потеряв имена полей.
Что делать дальше
Определитесь с изменяемостью для конкретной структуры и возьмите dataclass как значение по умолчанию — он покрывает большинство случаев и легче рефакторится. Перед оптимизацией памяти замерьте реальное потребление через tracemalloc на своих данных, а не на синтетике из статьи. Если структурам нужна валидация типов в рантайме или сериализация — смотрите в сторону pydantic или attrs, это следующий шаг и отдельная тема. Официальную семантику параметров декоратора держите под рукой: раздел dataclasses в документации Python описывает каждый флаг и версию, в которой он появился.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: dataclasses — Data Classes, официальная документация Python, typing.NamedTuple, официальная документация Python
Частые вопросы
А если у меня Python 3.9 — там же нет slots=True у dataclass?
Что быстрее по доступу к полям?
Можно ли сделать dataclass неизменяемым и хешируемым одновременно?
Почему мой dataclass нельзя положить в set?
NamedTuple из collections и из typing — это одно и то же?
Что выбрать под миллионы мелких объектов в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.