Python Dataclasses Explained: Defaults, Frozen and Ordering
The dataclass decorator writes the constructor, repr and equality from annotated fields. List defaults, the frozen, order and slots options, and validation.
- Course: Python study plan
- Module: Classes and objects
- Kind: Lesson
- Reading time: 14 min
- Runtime: CPython 3.11
What is a dataclass in Python?
A dataclass is a class decorated with @dataclass from the dataclasses module (Python 3.7+), which generates __init__, __repr__ and __eq__ from its annotated fields. Options add more: order=True for comparisons, frozen=True for immutable, hashable instances and slots=True for compact ones. The type annotations declare the fields but are not enforced.
Lesson
Most classes are records: a few named fields, an __init__ that stores them, a __repr__ that shows them, an __eq__ that compares them. Writing those by hand is twenty lines of boilerplate per class and a bug every time a field is added to one method and not the others. @dataclass (3.7) generates them from the field annotations, and its options add ordering, immutability, hashing, slots and keyword-only construction. This lesson covers the decorator, defaults and field, the generated methods, the options that matter, __post_init__ for validation and derived fields, the helpers asdict/astuple/replace, and when a plain class is still the better tool.
The basic form
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float = 0.0
p = Point(1.5)
p # Point(x=1.5, y=0.0) — __repr__
p == Point(1.5, 0) # True — __eq__ compares fields as a tuple
p.x = 2 # mutable by default
Each annotated class attribute becomes a field, in declaration order; the annotations are the field types (not enforced, like all hints). The decorator writes __init__(self, x, y=0.0), __repr__ and __eq__. Fields with defaults must come after those without, as in any signature.
Defaults and field()
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list[str] = field(default_factory=list) # a fresh list per instance
size_limit: int = 10
_id: int = field(default=0, repr=False, compare=False)
A mutable default (members: list = []) is refused with ValueError: mutable default … use default_factory — the dataclass machinery knows the trap from Module 4. default_factory takes a zero-argument callable, called per instance. field() also controls whether a field appears in repr, eq/ordering (compare=False), __init__ (init=False for a field computed later), and carries metadata.
Options
@dataclass(order=True) # __lt__, __le__, __gt__, __ge__ comparing fields in order
@dataclass(frozen=True) # assignment after __init__ raises; instances are hashable
@dataclass(slots=True) # __slots__ generated (3.10): smaller, no dynamic attributes
@dataclass(kw_only=True) # every field keyword-only in __init__ (3.10)
@dataclass(eq=False) # keep identity comparison and the default hash
order=True compares instances as tuples of their fields, which is right when the declaration order is the sort order (a Version(major, minor)); when it is not, define __lt__ yourself or put a sort key first. frozen=True gives a value object: p.x = 2 raises FrozenInstanceError, and because the fields cannot change, the decorator generates __hash__ so instances work as dict keys and set members. frozen=True, slots=True together is the tightest value type Python offers without writing dunders. kw_only=True prevents positional-argument mix-ups in classes with many same-typed fields.
Hashing follows the rules of Module 7: an eq=True (the default), non-frozen dataclass is unhashable; unsafe_hash=True forces a hash on a mutable class, which is exactly as unsafe as it sounds.
__post_init__
__init__ is generated, so validation and derived fields go in __post_init__, which runs right after it:
@dataclass
class Rect:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
if self.width < 0 or self.height < 0:
raise ValueError("negative dimension")
self.area = self.width * self.height
In a frozen dataclass a derived field is set with object.__setattr__(self, "area", …) inside __post_init__, because normal assignment is blocked. InitVar[T] declares a pseudo-field that is passed to __init__ and __post_init__ but not stored.
Helpers
from dataclasses import asdict, astuple, replace, fields
asdict(p) # {'x': 1.5, 'y': 0.0} — recursive on nested dataclasses
astuple(p) # (1.5, 0.0)
replace(p, y=3) # a new Point(x=1.5, y=3) — the frozen-friendly "modify"
[f.name for f in fields(Point)] # ['x', 'y']
asdict is the bridge to JSON (json.dumps(asdict(p))); replace is how a frozen instance is "changed". Dataclasses are ordinary classes: methods, properties, class methods (from_dict) and inheritance all work, and a subclass's fields are appended after the base's.
When not to use a dataclass
- The class has significant behaviour and invariants that
__init__should enforce in a specific order — a hand-written__init__is clearer than__post_init__gymnastics. - Equality should be identity (an account, a connection): use
eq=Falseor a plain class. - You need a
NamedTuple: same field syntax, always immutable, unpackable, slightly lighter, but it is a tuple (Point(1, 2) == (1, 2)isTrue, which is rarely wanted). - A plain dict is the right shape for loosely structured data that is never operated on.
The rule of thumb: a @dataclass for records with a fixed shape, frozen=True when they are values, a plain class when the constructor has logic, NamedTuple when tuple behaviour is wanted.
Pitfalls
- A mutable default without
default_factory(refused) — or a shared object passed asdefault=(accepted, and shared). order=Truewhen the field order is not the sort order.- Expecting type annotations to be checked.
- Forgetting that
asdictcopies deeply — large nested structures get duplicated. - Assigning in
__post_init__of a frozen class withoutobject.__setattr__. - A non-frozen dataclass used as a dict key (
TypeError).
Key takeaways
@dataclassgenerates__init__,__repr__and__eq__from annotated fields;field(default_factory=…)for mutable defaults.order=Truefor tuple-style ordering,frozen=Truefor immutable, hashable value objects,slots=Truefor compact instances,kw_only=Truefor safe construction.__post_init__validates and derives;InitVarpasses construction-only arguments.asdict,astuple,replace,fieldsare the helpers;replaceis how frozen instances change.- Use a plain class when the constructor has logic or equality is identity;
NamedTuplewhen you want a tuple.
Common questions
How do I use a list as a default value in a dataclass?
Use field(default_factory=list), which calls list() to make a fresh list for each instance. A plain members: list = [] is refused with a ValueError that points you to default_factory, because one list would otherwise be shared by every instance.
What does frozen=True do in a dataclass?
It makes instances immutable: assigning a field after __init__ raises FrozenInstanceError. Because the fields cannot change, the decorator also generates __hash__, so frozen instances work as dict keys and set members, and dataclasses.replace(p, x=1) returns a modified copy.
What is __post_init__ in a dataclass?
__post_init__ runs right after the generated __init__, so validation and derived fields go there: raise ValueError for bad values, or compute a field declared with field(init=False). In a frozen dataclass, set a derived field with object.__setattr__.
What is the difference between a dataclass and a namedtuple?
A NamedTuple is always immutable and really is a tuple, so it unpacks, indexes and compares equal to a plain tuple — Point(1, 2) == (1, 2) is True. A dataclass is an ordinary class, mutable by default, with options for ordering, freezing and slots.
Why is my dataclass unhashable?
A dataclass that keeps the default eq=True and is not frozen has __hash__ set to None, because a mutable object whose equality depends on its fields must not be a key. Use @dataclass(frozen=True) for a hashable value type.
Exercises
Employee records
Define @dataclass(order=True) Employee with fields dept: str, salary: int, name: str — in that order, so the generated ordering sorts by department, then salary, then name. Read n records as name dept salary, print them sorted with plain sorted(), then print asdict of the first sorted record as JSON with sorted keys, then a copy of it made with replace(salary=...) doubled.
Input: n, then n lines. Output: n lines Employee(dept='eng', salary=100, name='ada') (the generated repr), then the JSON line, then the replaced record's repr.
2
bob ops 90
ada eng 100
prints
Employee(dept='eng', salary=100, name='ada')
Employee(dept='ops', salary=90, name='bob')
{"dept": "eng", "name": "ada", "salary": 100}
Employee(dept='eng', salary=200, name='ada')Frozen points
Define @dataclass(frozen=True) Point with integer x and y and a __post_init__ that raises ValueError when either is negative. Read n lines x y; for each print ok or invalid. Then print the number of distinct valid points (they are hashable), and try to assign x = 0 on the first valid point — print frozen when FrozenInstanceError is raised — and finally print replace(first, x=0).
Input: n, then n lines. Output: n lines, then distinct <k>, frozen, and the replaced repr (the last two only if there is a valid point).
3
1 2
-1 0
1 2
prints
ok
invalid
ok
distinct 1
frozen
Point(x=0, y=2)In this module: Classes and objects
- Defining classes — __init__, self, attributes and methods
- Dunder methods — making a class behave like a built-in
- Properties and encapsulation — the underscore, @property and __slots__
- Class methods and static methods
- Dataclasses — classes that are mostly data (this lesson)
- Designing a class — invariants, interfaces and a worked example
- Checkpoint — Classes and objects
← Class methods and static methods · Designing a class — invariants, interfaces and a worked example →