The dataclasses module in the standard library writes the boilerplate for you. One decorator on a class gives you __init__, __repr__ and __eq__ generated from the fields you declare, which turns a data model from thirty lines of repetition into a handful of declarations.
It arrived in Python 3.7 and has been the standard answer to "I need a class that just holds some values" ever since. The key insight is that a dataclass is not a new kind of class — it is an ordinary class that gets a few dunder methods filled in for you at class creation time.
What does the dataclass decorator actually generate?
Decorate a class with @dataclass, list its annotated attributes, and you get a class with an __init__ that assigns each field, a __repr__ that prints every field, and an __eq__ that compares field by field. The declarations become the source of truth and the methods are generated from them.
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
stock: int
item = Product("keyboard", 79.5, 12)
print(item)
print(item.name, item.price)
print("equal?", item == Product("keyboard", 79.5, 12))
print("fields:", item.__dataclass_fields__.keys())
Two details in that snippet matter. The annotations are not decoration — they are how the decorator discovers which attributes are fields. An attribute without an annotation is not a field and is not given a parameter. And the generated __init__ takes the fields in the order you declared them, so the order matters for positional calls.
How do defaults work, and what about mutable ones?
A field with a plain default becomes an optional parameter. But a mutable default — a list, a dict, a set — is the same trap you hit with function arguments: it is shared across every instance. The dataclasses module refuses to allow it, and raises a ValueError at class definition time if you try.
from dataclasses import dataclass, field
@dataclass
class Cart:
owner: str
items: list = field(default_factory=list)
coupon: str = None
first = Cart("Asha")
second = Cart("Ben")
first.items.append("keyboard")
print("first :", first.items)
print("second:", second.items)
print("the lists are different objects:", first.items is not second.items)
The fix is field(default_factory=list). The factory is a callable that Python invokes once per instance, producing a fresh empty list every time. Use default_factory=dict, default_factory=set or any other zero-argument callable the same way.
There is also a default argument on the field() helper for the same purpose, and init=False to mark a field that should not appear in the generated constructor — useful for a value the class computes for itself.
What do frozen and order do?
@dataclass(frozen=True) makes instances immutable. Any attempt to assign to a field after construction raises FrozenInstanceError. This is a strong, cheap guarantee: you can pass the object around without worrying that someone will mutate it, and because it is immutable it also gets a working __hash__ for free.
@dataclass(order=True) adds the comparison operators, so instances can be sorted and compared with <. It requires the fields to be orderable, and it generates them in field-declaration order, so put your primary sort key first.
from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)
class Point:
x: int
y: int
@dataclass(order=True)
class Job:
priority: int
label: str
p = Point(1, 2)
try:
p.x = 99
except FrozenInstanceError as exc:
print("FrozenInstanceError:", exc)
jobs = [Job(3, "third"), Job(1, "first"), Job(2, "second")]
print([job.label for job in sorted(jobs)])
There is one more option worth knowing: slots=True, available since Python 3.10. It generates a __slots__ attribute, which removes the per-instance __dict__. That makes instances smaller and slightly faster to create, and it stops you from typos adding stray attributes at runtime.
How do you validate or derive values after construction?
Define __post_init__ and the generated __init__ calls it once the fields are assigned. It is the place for validation, and for computing derived values that you do not want callers to pass in.
from dataclasses import dataclass
@dataclass
class ServerConfig:
host: str = "localhost"
port: int = 8080
def __post_init__(self):
if not 1 <= self.port <= 65535:
raise ValueError(f"port {self.port} is out of range")
print(ServerConfig())
print(ServerConfig("example.com", 443))
try:
ServerConfig(port=70000)
except ValueError as exc:
print("rejected:", exc)
For conversion, the module gives you asdict and astuple to turn an instance into a plain dict or tuple, and replace to build a modified copy. replace is better than mutating, because it works with frozen dataclasses and it goes through __post_init__ again, so derived fields stay consistent.
One caveat about replace: it performs a shallow copy. A new instance is created and its fields are reassigned, but a mutable field such as a list is carried over by reference, not duplicated. The snippet below makes that visible — editing the copy's list also edits the original's.
from dataclasses import dataclass, field, asdict, astuple, replace
@dataclass
class ServerConfig:
host: str = "localhost"
port: int = 8080
tags: list = field(default_factory=list)
config = ServerConfig(tags=["prod"])
print("asdict :", asdict(config))
print("astuple:", astuple(config))
updated = replace(config, port=9090)
print("original port:", config.port, "| copy port:", updated.port)
updated.tags.append("eu")
print("tags after editing the copy:", config.tags, updated.tags)
When should you skip the dataclass?
A dataclass is a great fit for a record with a handful of fields and simple behaviour. It is a poor fit when the class needs custom construction logic, when it holds a resource with a lifetime, or when the "fields" are computed rather than stored. If your __init__ needs to do real work, write the class by hand — at that point you are fighting the decorator rather than using it.
It is also worth knowing that a dataclass is a normal class underneath. You can add methods, properties and even non-field class attributes to it, and mix in behaviour from other classes as usual.
| You want | Reach for |
|---|---|
| a plain record with a few fields | @dataclass |
| the same but guaranteed immutable | @dataclass(frozen=True) |
| sortable records | @dataclass(order=True) |
| custom construction logic | a hand-written class |
| a type with a resource lifetime | a hand-written class with explicit setup and teardown |
Before you move on
- Annotate the fields you want, then decorate with
@dataclass. - Use
field(default_factory=list)for mutable defaults, never a bare list. frozen=Truegives immutability and hashing;order=Truegives sorting;slots=True(3.10+) saves memory.- Put validation and derived values in
__post_init__. - Use
asdict,astupleandreplaceinstead of hand-rolling conversions.
Try it yourself: a config record with validation
Write a ServerConfig dataclass with host (default "localhost"), port (default 8080), tags (a list built with default_factory) and debug (default False). Validate in __post_init__ that the port is between 1 and 65535, raising ValueError otherwise.
Then make a second dataclass called FrozenServerConfig with frozen=True and confirm that assigning to a field raises. Add order=True to a third dataclass holding a name and a port, build three of them, and sort them. Finish by using replace() to make a copy of a config with a different port, then edit the copy's tags list and check whether the original's list changed too — replace is a shallow copy, so it does. Build a genuinely independent copy with copy.deepcopy and confirm the difference.