Python Dataclasses Explained: Less Boilerplate, Safer Defaults and Real Examples
Dataclasses write the dull parts of a class for you: the constructor, a readable repr and equality checks. Learn the options that matter, from default_factory to frozen and slots, with every example run.
- The
@dataclassdecorator generates__init__,__repr__and__eq__from type-annotated fields. - Use
field(default_factory=list)for lists and dicts. A plain[]default is rejected. frozen=Truemakes instances read-only and hashable.order=Truemakes them sortable.- Type hints are not enforced at runtime, so validate important values in
__post_init__.
Writing a class to hold a product’s name, price and quantity used to mean a constructor that copies each argument, a __repr__ so it prints usefully, and an __eq__ so two identical products compare equal. That is a lot of code that says nothing interesting. Dataclasses, added in Python 3.7, generate all of it from a short list of fields.
We ran every example below with Python 3.13, and the outputs shown are from that run. If classes are new to you, start with our Python classes and objects guide.
What is a dataclass?
A dataclass is an ordinary Python class decorated with @dataclass from the standard library dataclasses module. The decorator reads the class’s annotated attributes and writes the common special methods for you. You can still add your own methods and properties, so a dataclass is not limited to holding data. It simply starts from a sensible base.
Your first dataclass
from dataclasses import dataclass, asdict, astuple
@dataclass
class Product:
name: str
price: float
qty: int = 0
p = Product("Pen", 10.0, 3)
print(p) # Product(name='Pen', price=10.0, qty=3)
print(p == Product("Pen", 10.0, 3)) # True
print(asdict(p), astuple(p))
# {'name': 'Pen', 'price': 10.0, 'qty': 3} ('Pen', 10.0, 3)
Three lines of fields gave us a constructor with a default for qty, a readable printout and value-based equality. Without the decorator, p == Product("Pen", 10.0, 3) would be False, because normal classes compare by identity. asdict() is handy for turning objects into JSON-ready dictionaries, and it converts nested dataclasses too.
Defaults and default_factory
Mutable defaults are a classic Python trap: a single list shared by every instance. Dataclasses protect you from the most common form of it. When we tried a list default, the class was rejected when it was defined:
@dataclass
class Bad:
tags: list = []
# ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
The fix is default_factory, which calls a function to create a fresh value for each instance:
from dataclasses import field
@dataclass
class Cart:
owner: str
items: list = field(default_factory=list)
a, b = Cart("Asha"), Cart("Ben")
a.items.append("pen")
print(a.items, b.items) # ['pen'] []
Each cart got its own list. Fields with defaults must also come after fields without them, just as in a normal function signature. We got TypeError: non-default argument 'b' follows default argument 'a' when we broke that rule.
Frozen dataclasses
from dataclasses import FrozenInstanceError, replace
@dataclass(frozen=True)
class Point:
x: int
y: int
pt = Point(1, 2)
pt.x = 5
# FrozenInstanceError: cannot assign to field 'x'
print({pt: "origin-ish"}[Point(1, 2)]) # origin-ish
print(replace(pt, x=9)) # Point(x=9, y=2)
A frozen dataclass cannot be changed after creation, and it is hashable, so it can be a dictionary key or a set member. To “change” one, replace() returns a new copy with some fields swapped. Immutable objects are easier to reason about, especially when they are passed between functions or threads. Note that frozen is shallow: a list stored in a frozen field can still be modified.
Ordering
@dataclass(order=True)
class Version:
major: int
minor: int
print(sorted([Version(1, 10), Version(1, 2), Version(0, 9)]))
# [Version(major=0, minor=9), Version(major=1, minor=2), Version(major=1, minor=10)]
order=True compares instances field by field, in the order the fields are declared, like comparing tuples. Version 1.10 correctly sorted after 1.2, which plain string sorting gets wrong, a topic we cover in semantic versioning explained. To leave a field out of comparisons, use field(compare=False).
Validation with __post_init__
The generated __init__ calls __post_init__ at the end, if you define one. It is the place for validation and calculated fields:
@dataclass
class Order:
subtotal: float
tax_rate: float = 0.18
total: float = field(init=False)
def __post_init__(self):
if self.subtotal < 0:
raise ValueError("subtotal cannot be negative")
self.total = round(self.subtotal * (1 + self.tax_rate), 2)
print(Order(100)) # Order(subtotal=100, tax_rate=0.18, total=118.0)
Order(-1) # ValueError: subtotal cannot be negative
init=False keeps total out of the constructor, since it is always calculated. Validating in one place means a bad Order can never exist, which saves defensive checks everywhere else.
Type hints are not checks
This catches many beginners. The annotations tell the decorator which attributes are fields, but Python does not enforce them:
print(Product("Book", "free"))
# Product(name='Book', price='free', qty=0)
A string slipped into a field annotated as float without complaint. A type checker such as mypy or Pyright would flag this in your editor or CI, and __post_init__ can check it at runtime. When you need full validation and conversion of outside data, such as JSON from an API, a library like Pydantic is a better fit.
slots and kw_only
Python 3.10 added two useful options.
@dataclass(slots=True)
class Slim:
a: int
b: int
Slim(1, 2).c = 3
# AttributeError: 'Slim' object has no attribute 'c' and no __dict__ for setting new attributes
slots=True removes the per-instance dictionary. That blocks attribute typos like the one above and saves memory. In our measurement a slotted instance took 48 bytes, while a normal instance plus its attribute dictionary took 336 bytes. That difference adds up when you create millions of objects.
@dataclass(kw_only=True)
class Config:
host: str
port: int = 8000
print(Config(host="localhost")) # Config(host='localhost', port=8000)
Config("localhost")
# TypeError: Config.__init__() takes 1 positional argument but 2 were given
kw_only=True forces callers to name every argument. For configuration objects with many fields, this prevents values being passed in the wrong order, the same benefit described in our *args and **kwargs guide.
field() options
| Option | Effect |
|---|---|
default |
A fixed default value |
default_factory |
A function that creates a fresh default |
init=False |
Leave the field out of __init__ |
repr=False |
Hide it when printing, useful for secrets |
compare=False |
Ignore it in equality and ordering |
kw_only=True |
Make just this field keyword-only |
@dataclass
class Secret:
user: str
password: str = field(repr=False)
print(Secret("asha", "hunter2")) # Secret(user='asha')
Hiding sensitive fields from repr keeps them out of logs and error messages, which matters for the same reasons covered in our environment variables guide.
Dataclass vs alternatives
| Option | Mutable | Validation | Best for |
|---|---|---|---|
| Dataclass | Yes, or frozen | Manual | Internal data objects |
NamedTuple |
No | None | Small, tuple-like records |
TypedDict |
Yes | Type checker only | Describing dict-shaped JSON |
| Pydantic model | Yes | Automatic | Parsing outside data, APIs |
| attrs | Yes, or frozen | Validators built in | Advanced needs, older Pythons |
Common mistakes
- Forgetting annotations. An attribute without a type hint is a class variable, not a field.
- Using
[]or{}as a default. Usedefault_factory. - Expecting type checking. Add a type checker or validate in
__post_init__. - Writing your own
__init__. It replaces the generated one. Use__post_init__instead. - Assuming frozen means deeply immutable. Nested lists can still change.
Inheritance and JSON round trips
Dataclasses can inherit from each other. Fields from the parent come first, followed by the child’s fields:
@dataclass
class Base:
id: int
created_by: str = "system"
@dataclass
class Invoice(Base):
amount: float = 0.0
lines: list = field(default_factory=list)
inv = Invoice(7, amount=99.5, lines=["pen"])
print(inv)
# Invoice(id=7, created_by='system', amount=99.5, lines=['pen'])
Because parent fields come first, a child field without a default cannot follow a parent field that has one. Our attempt failed with non-default argument 'amount' follows default argument 'created_by'. Marking the child with @dataclass(kw_only=True) solved it, since keyword-only fields have no ordering rule.
Converting to and from JSON is a two-liner for flat data:
s = json.dumps(asdict(inv))
# {"id": 7, "created_by": "system", "amount": 99.5, "lines": ["pen"]}
back = Invoice(**json.loads(s))
print(back == inv) # True
This works only while the JSON matches the fields exactly. An extra key raised Invoice.__init__() got an unexpected keyword argument 'unknown', and nested dataclasses, dates or decimals need converting by hand. That is the point where a library such as Pydantic, or a dedicated converter, starts to save real time.
Final thoughts
Dataclasses remove boilerplate without hiding how Python classes work. Start with a plain @dataclass, reach for default_factory for collections, frozen=True for values that should never change, and __post_init__ for validation. When data arrives from outside your program, consider a validating library instead.
Frequently asked questions
What is a dataclass in Python?
It is a class decorated with @dataclass, which generates methods such as __init__, __repr__ and __eq__ from the class’s type-annotated fields. It was added in Python 3.7.
How do I use a list as a default in a dataclass?
Use field(default_factory=list). A plain [] default raises a ValueError, because it would be shared by every instance.
What does frozen=True do?
It makes instances read-only after creation and hashable, so they can be used as dictionary keys. Use dataclasses.replace() to create a modified copy.
Do dataclasses check types?
No. Type hints are not enforced at runtime. Use a type checker such as mypy, validate in __post_init__, or use a library such as Pydantic.
Dataclass or Pydantic?
Use a dataclass for internal objects you create yourself. Use Pydantic when parsing and validating outside data such as JSON requests or config files.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.