dataclasses: Lean Data Classes in Python
1. Overview
When writing a class just to hold data (like Order, Customer, Point…), you typically have to write __init__, __repr__, __eq__ yourself — all boilerplate code repeated over and over. The dataclasses module (available since Python 3.7) helps you auto-generate these methods with just a decorator. After reading this, you’ll know how to use @dataclass to write concise data classes, validate data with __post_init__, make classes immutable, and convert to dict/JSON.
2. What is a Basic Dataclass?
@dataclass is a decorator that reads type hints in a class, then auto-generates __init__, __repr__, __eq__ based on those fields.
from dataclasses import dataclass
@dataclass
class Customer:
name: str
email: str
age: int = 0
@dataclassscans attributes with type hints (name,email,age) and treats them as fields.age: int = 0has a default value, like default parameters in functions.- You don’t need to write
__init__— it’s auto-generated in the exact order of fields.
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)
The auto-generated __repr__ displays all field names and values, very handy for debugging — classes don’t have this by default.
3. Comparing with Plain Classes and namedtuple
Writing an equivalent class by hand is much more verbose:
class CustomerPlain:
def __init__(self, name, email, age=0):
self.name = name
self.email = email
self.age = age
def __repr__(self):
return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"
def __eq__(self, other):
if not isinstance(other, CustomerPlain):
return NotImplemented
return (self.name, self.email, self.age) == (other.name, other.email, other.age)
@dataclass writes all of this for you. Unlike namedtuple, dataclass differs in that:
- Objects are mutable by default (you can modify
c1.age = 20), namedtuple is not. - Supports explicit type hints, integrates well with type checkers like
mypy. - Supports
__post_init__, complex defaults, inheritance — thingsnamedtupledoesn’t handle well.
Note: If you just need a lightweight immutable tuple without methods,
namedtupleortyping.NamedTupleis still a more concise choice.
4. field() and Mutable Default Values
A very common mistake: using list/dict as default values directly.
from dataclasses import dataclass, field
@dataclass
class Order:
order_id: str
items: list[str] = field(default_factory=list)
- If you write
items: list[str] = [], Python raisesValueError: mutable default ... is not allowed— because the default list would be shared across all instances, causing hard-to-find bugs. field(default_factory=list)tells dataclass to calllist()fresh for each instance.
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Book")
print(o1.items, o2.items)
['Book'] []
The two objects have independent items lists, as expected.
5. Validating Data with __post_init__
The dataclass-generated __init__ doesn’t let you insert validation logic directly — but there’s a __post_init__ hook that runs right after __init__ completes.
from dataclasses import dataclass
@dataclass
class PaymentRequest:
amount: float
currency: str = "VND"
def __post_init__(self):
if self.amount <= 0:
raise ValueError("amount must be greater than 0")
self.currency = self.currency.upper()
__post_init__is called automatically after all fields are assigned.- Here we validate
amountand normalizecurrencyto uppercase.
try:
PaymentRequest(amount=-100)
except ValueError as e:
print(e)
amount must be greater than 0
6. Immutable Classes with frozen=True
If you want objects to be unchangeable after creation (like records in Java), use frozen=True.
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
frozen=Trueblocks__setattr__— any assignment after initialization raises an error.- Thanks to immutability,
frozenobjects can be used as dict keys or set members (because dataclass auto-generates__hash__when bothfrozen=Trueandeq=True).
p = Point(1.0, 2.0)
try:
p.x = 5.0
except Exception as e:
print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'
Note:
frozen=Trueonly blocks top-level attribute assignment. If a field is a list/dict (mutable), you can still modify its contents. For true immutability, usetupleor immutable types for that field.
7. Inheriting from Dataclasses
Dataclasses support normal inheritance like Python classes, with subclass fields appended after parent class fields.
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
@dataclass
class Employee(Person):
salary: float = 0.0
Employeeinheritsname,agefromPerson, then addssalary.- The parameter order in the generated
__init__is:name, age, salary— following declaration order.
e = Employee("Binh", 30, salary=15_000_000)
print(e)
Employee(name='Binh', age=30, salary=15000000.0)
Note: Fields without defaults must come before fields with defaults across the entire inheritance chain. If a parent has a field with a default and a child adds a field without one, Python raises
TypeErrorwhen defining the class.
8. Comparison and Sorting with order=True
By default, dataclass only generates __eq__. To compare with <, >, <=, use order=True.
from dataclasses import dataclass
@dataclass(order=True)
class Product:
price: float
name: str = field(compare=False)
order=Truegenerates__lt__,__le__,__gt__,__ge__, comparing in field order (like tuple comparison).field(compare=False)excludesnamefrom comparisons — onlypriceis compared.
products = [Product(50_000, "Pen"), Product(20_000, "Notebook")]
print(sorted(products))
[Product(price=20000, name='Notebook'), Product(price=50000, name='Pen')]
sorted() uses the __lt__ generated by dataclass, no need to write key=lambda p: p.price manually.
9. Converting to Dict and Tuple
dataclasses provides utility functions asdict and astuple for serializing objects.
from dataclasses import dataclass, asdict, astuple
@dataclass
class Address:
city: str
country: str
@dataclass
class Customer2:
name: str
address: Address
cust = Customer2("Chi", Address("Hanoi", "Vietnam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hanoi', 'country': 'Vietnam'}}
('Chi', ('Hanoi', 'Vietnam'))
asdictrecursively converts nested dataclasses (Address) to nested dicts — very handy for returning JSON via APIs.astupleis similar but returns nested tuples.
Note:
asdict/astupledeep-copy all data. With large objects or fields that can’t be copied (file handles, connections…), calling these functions will be slow or fail.
10. Writing Tests for Dataclasses
Since __eq__ is auto-generated based on field values, testing object equality is very natural with pytest.
import pytest
from dataclasses import FrozenInstanceError
def test_customer_equality():
c1 = Customer("An", "an@example.com", 20)
c2 = Customer("An", "an@example.com", 20)
assert c1 == c2
def test_payment_request_rejects_non_positive_amount():
with pytest.raises(ValueError):
PaymentRequest(amount=0)
def test_point_is_frozen():
p = Point(1.0, 2.0)
with pytest.raises(FrozenInstanceError):
p.x = 10.0
test_customer_equalitychecks that two different instances with the same field values are still equal — thanks to the auto-generated__eq__.test_payment_request_rejects_non_positive_amountchecks that__post_init__validates correctly.test_point_is_frozenconfirms thatfrozen=Trueblocks attribute assignment.
11. Common Pitfalls and When Not to Use
- Forgetting
default_factoryfor mutable defaults: using= []or= {}directly fails immediately when declaring the class, you must usefield(default_factory=...). - Mixing fields with and without defaults in wrong order during inheritance: causes
TypeErrorat module load, hard to debug if classes are scattered across files. - Thinking
frozen=Truemeans deep immutability: list/dict fields can still have their contents modified, only top-level attribute assignment is blocked. - Using dataclass for complex business logic: if a class has many processing methods and complex relationships with other objects, dataclass only helps with initialization/comparison — you still write the logic yourself. Don’t misuse it to pretend the class is simpler than it really is.
- Need Python < 3.7 compatibility:
dataclassesisn’t built-in, you must install thedataclassespackage from PyPI (backport) or useattrs.
Note: If you need complex validation, custom serialization, or deep JSON schema integration, libraries like
pydanticare often stronger than plaindataclasses— worth considering for larger projects.
12. Conclusion
dataclasses helps you eliminate most boilerplate when writing data-holding classes: auto-generating __init__, __repr__, __eq__, supporting validation via __post_init__, immutability via frozen=True, and easy serialization via asdict/astuple. For simple to moderate data classes, it’s a much more concise and readable choice than hand-writing. When validation or serialization needs become more complex, consider pydantic or attrs as alternatives.