Python gives you four reasonable ways to hold a few related values. Choosing badly is rarely fatal and always mildly annoying, so it is worth ten minutes once.
The four
d = {'x': 3, 'y': 4}
print(d, d['x'])
print('typo is silent:', d.get('z'))
try:
d['z']
except KeyError as err:
print('KeyError:', err)
{'x': 3, 'y': 4} 3
typo is silent: None
KeyError: 'z'
class P1:
def __init__(self, x, y): self.x, self.y = x, y
print(P1(3,4).x)
@dataclass
class P2:
x: int
y: int
print(P2(3,4), P2(3,4) == P2(3,4))
class P3(NamedTuple):
x: int
y: int
p3 = P3(3,4)
print(p3, p3.x, tuple(p3), p3 == (3,4))
3
P2(x=3, y=4) True
P3(x=3, y=4) 3 (3, 4) True
The dataclass gives you __init__, __repr__ and __eq__ from the annotations. The NamedTuple gives you all that plus immutability, tuple unpacking, and equality with plain tuples.
Mutability
p2 = P2(3,4); p2.x = 99
print('dataclass mutable:', p2)
try:
p3.x = 99
except AttributeError as err:
print('NamedTuple immutable:', type(err).__name__ + ':', err)
@dataclass(frozen=True)
class P4:
x: int
y: int
print('frozen dataclass hashable:', hash(P4(3,4)) == hash(P4(3,4)))
dataclass mutable: P2(x=99, y=4)
NamedTuple immutable: AttributeError: can't set attribute
frozen dataclass hashable: True
frozen=True gets you a dataclass with a NamedTuple’s guarantees, and hashability with it.
The deciding question: what happens to a typo
This is the one that actually matters day to day.
p = P2(3,4)
p.z = 5 # a plain/dataclass object accepts anything
print('dataclass accepts a typo:', p.z)
try:
P3(3,4).z
except AttributeError as err:
print('NamedTuple rejects it:', type(err).__name__ + ':', err)
dataclass accepts a typo: 5
NamedTuple rejects it: 'P3' object has no attribute 'z'
A dict silently returns None from .get(). A plain class or dataclass silently accepts a new attribute you never declared — p.z = 5 is not an error, so a misspelled assignment creates a field nobody reads. A NamedTuple refuses both.
That is the strongest argument for NamedTuple or a frozen dataclass on data you pass around: the typo fails where you made it, not three functions later. Part 10 shows how __slots__ gives a mutable class the same protection.
Memory
print('dict ', sys.getsizeof({'x':3,'y':4}))
print('NamedTuple ', sys.getsizeof(P3(3,4)))
print('class ', sys.getsizeof(P1(3,4)) + sys.getsizeof(P1(3,4).__dict__))
print('dataclass ', sys.getsizeof(P2(3,4)) + sys.getsizeof(P2(3,4).__dict__))
dict 184
NamedTuple 56
class 328
dataclass 304
A NamedTuple is a tuple with names, so it carries no per-instance __dict__. Classes and dataclasses do, and it dominates their size.
Treat these numbers as a shape, not a benchmark — they vary by version and by what is stored. The shape is stable: NamedTuple is much smaller, dict is in between, objects with a __dict__ are largest. It only matters at hundreds of thousands of instances.
How to choose
dict — the shape is genuinely dynamic, comes from JSON, or you do not know the keys ahead of time. Stop converting external JSON into objects out of habit.
NamedTuple — a small immutable value you pass around and compare. Cheapest, safest against typos, works with tuple unpacking.
dataclass — the default for anything you wrote yourself with more than about three fields, or that needs to change after construction. Add frozen=True unless you have a reason not to.
plain class — when behaviour matters more than the data: methods, validation in __init__, computed properties. That is Part 1’s bank account, and it is a different job.
What to remember
-
A dict typo is silent; a class or dataclass typo silently creates a field.
-
NamedTuple and
frozen=Truedataclasses reject both. -
Reach for a dataclass by default,
frozen=Trueunless something needs to mutate. -
Keep external JSON as a dict until you have a reason to give it a type.