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
  • @dataclass scans attributes with type hints (name, email, age) and treats them as fields.
  • age: int = 0 has 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 — things namedtuple doesn’t handle well.

Note: If you just need a lightweight immutable tuple without methods, namedtuple or typing.NamedTuple is 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 raises ValueError: 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 call list() 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 amount and normalize currency to 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=True blocks __setattr__ — any assignment after initialization raises an error.
  • Thanks to immutability, frozen objects can be used as dict keys or set members (because dataclass auto-generates __hash__ when both frozen=True and eq=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=True only blocks top-level attribute assignment. If a field is a list/dict (mutable), you can still modify its contents. For true immutability, use tuple or 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
  • Employee inherits name, age from Person, then adds salary.
  • 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 TypeError when 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=True generates __lt__, __le__, __gt__, __ge__, comparing in field order (like tuple comparison).
  • field(compare=False) excludes name from comparisons — only price is 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'))
  • asdict recursively converts nested dataclasses (Address) to nested dicts — very handy for returning JSON via APIs.
  • astuple is similar but returns nested tuples.

Note: asdict/astuple deep-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_equality checks that two different instances with the same field values are still equal — thanks to the auto-generated __eq__.
  • test_payment_request_rejects_non_positive_amount checks that __post_init__ validates correctly.
  • test_point_is_frozen confirms that frozen=True blocks attribute assignment.

11. Common Pitfalls and When Not to Use

  • Forgetting default_factory for mutable defaults: using = [] or = {} directly fails immediately when declaring the class, you must use field(default_factory=...).
  • Mixing fields with and without defaults in wrong order during inheritance: causes TypeError at module load, hard to debug if classes are scattered across files.
  • Thinking frozen=True means 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: dataclasses isn’t built-in, you must install the dataclasses package from PyPI (backport) or use attrs.

Note: If you need complex validation, custom serialization, or deep JSON schema integration, libraries like pydantic are often stronger than plain dataclasses — 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.

pythondataclassesoopclean-code