Simplify Data Classes with Python `dataclasses`

Learn how to use Python's dataclasses module to reduce boilerplate when creating classes for storing data.

7 min read

The dataclasses module in the Python standard library (added in Python 3.7) eliminates the boilerplate of writing init, repr, and eq for classes that primarily store data. You define fields with type annotations, add the @dataclass decorator, and the methods are generated automatically.

Without dataclasses, a simple data class needs a lot of repetitive code just to get equality checks and a readable string representation working correctly:

pythonpython
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
 
    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"
 
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

With dataclasses, the same thing is three lines, and the decorator writes the init, repr, and eq methods shown above for you automatically:

pythonpython
from dataclasses import dataclass
 
@dataclass
class Point:
    x: float
    y: float
 
p1 = Point(1.5, 2.5)
p2 = Point(1.5, 2.5)
print(p1)               # Point(x=1.5, y=2.5)
print(p1 == p2)   # True

@dataclass generates init, repr, and eq from the annotated fields. The class behaves exactly like the hand-written version.

Default values

Not every field needs a value at construction time. Provide defaults by assigning values directly to fields in the class body, the same way you would set a class attribute. Fields with defaults must come after fields without defaults, or Python raises a TypeError when the class is defined.

pythonpython
from dataclasses import dataclass
 
@dataclass
class User:
    name: str
    active: bool = True
    score: int = 0
 
u = User("Maya")
print(u)     # User(name='Maya', active=True, score=0)

Simple defaults like True and 0 work fine directly on the field, but mutable defaults need a different approach. For mutable defaults like lists or dictionaries, use field(default_factory=...) instead of a direct default value. Direct mutable defaults are shared across all instances, which is almost never what you want.

pythonpython
from dataclasses import dataclass, field
 
@dataclass
class ShoppingCart:
    items: list = field(default_factory=list)
    owner: str = "guest"
 
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.items.append("apple")
print(cart1.items)   # ['apple']
print(cart2.items)   # []

default_factory=list creates a new empty list for each instance. Each cart's items list is independent, so appending to one never affects the other.

Immutable dataclasses

Pass frozen=True to make all fields read-only after initialization, which is useful for configuration objects and values you never want mutated once created.

pythonpython
from dataclasses import dataclass
 
@dataclass(frozen=True)
class Config:
    host: str
    port: int = 8080
    debug: bool = False
 
config = Config("localhost")
print(config.host)
config.port = 9000

Reading host works normally, but the assignment to port on the last line raises an exception instead of silently overwriting the field:

texttext
localhost
dataclasses.FrozenInstanceError: cannot assign to field 'port'

Frozen dataclasses are hashable (if all fields are hashable), so they can be used as dictionary keys or set members. They behave like immutable records.

Post-initialization with post_init

Use post_init to run validation or compute derived fields after the generated init runs. It runs automatically right after the fields are assigned, with no extra call needed.

pythonpython
from dataclasses import dataclass, field
 
@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)
 
    def __post_init__(self):
        if self.width <= 0 or self.height <= 0:
            raise ValueError("Dimensions must be positive")
        self.area = self.width * self.height

With the class defined, creating a rectangle computes and validates the area automatically, without the caller having to pass it in or call a separate method:

pythonpython
r = Rectangle(10, 5)
print(r.area)   # 50

field(init=False) excludes area from the generated init. post_init calculates it from the other fields and validates the input.

Converting to and from dictionaries

Use dataclasses.asdict() and dataclasses.astuple() to convert instances to dictionaries and tuples.

pythonpython
from dataclasses import dataclass, asdict, astuple
 
@dataclass
class Book:
    title: str
    author: str
    year: int
 
b = Book("Dune", "Frank Herbert", 1965)
print(asdict(b))    # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
print(astuple(b))  # ('Dune', 'Frank Herbert', 1965)

asdict() is useful for serializing to JSON. For nested dataclasses, use asdict(b) and then pass to json.dumps.

Ordering and comparison

By default, dataclasses support == and != by comparing all fields for equality. To add ordering (<, >, <=, >=), pass order=True so instances can be sorted directly.

Decorator optionSupportsComparison basis
@dataclass (default)==, !=All fields, in definition order
@dataclass(order=True)==, !=, <, >, <=, >=All fields, in definition order
pythonpython
from dataclasses import dataclass
 
@dataclass(order=True)
class Player:
    score: int
    name: str
 
players = [Player(85, "Devin"), Player(92, "Maya"), Player(78, "Priya")]
for p in sorted(players):
    print(p)

sorted() uses the generated comparison methods to order the players from lowest score to highest, printing one Player per line:

texttext
Player(score=78, name='Priya')
Player(score=85, name='Devin')
Player(score=92, name='Maya')

With order=True, instances are compared field by field in definition order, so score is listed first here specifically to sort players by score. Reversing the field order would sort by name instead.

Inheritance

Dataclasses support inheritance the same way regular classes do. The child class inherits fields from the parent and can add its own on top.

pythonpython
from dataclasses import dataclass
 
@dataclass
class Vehicle:
    make: str
    model: str
 
@dataclass
class Car(Vehicle):
    doors: int = 4
 
c = Car("Tesla", "Model 3")
print(c)

The generated init accepts the parent's fields first, followed by the child's own fields, in the order both classes declare them:

texttext
Car(make='Tesla', model='Model 3', doors=4)

Fields from the parent come first in init. A child can override a parent's default value by re-declaring the field.

Practical example: API response model

Use dataclasses to model API responses with type safety and automatic serialization. Nested dataclasses, like an Address inside a User, work the same as any other field type.

pythonpython
from dataclasses import dataclass, field, asdict
from typing import List
import json
 
@dataclass
class Address:
    street: str
    city: str
    zipcode: str

Address is a plain nested dataclass. User references it as a field type, with a default_factory so each user starts with its own empty address list:

pythonpython
@dataclass
class User:
    id: int
    name: str
    email: str
    addresses: List[Address] = field(default_factory=list)
    active: bool = True

With both classes defined, build a user with one nested address and serialize the whole structure to JSON in a single call:

pythonpython
address = Address("123 Main St", "Springfield", "62701")
user = User(1, "Maya", "maya@example.com", [address])
 
print(json.dumps(asdict(user), indent=2))

asdict() recursively converts nested dataclasses, so the Address instance turns into a plain dictionary inside the output automatically, with indent=2 producing readable, pretty-printed JSON:

jsonjson
{
  "id": 1,
  "name": "Maya",
  "email": "maya@example.com",
  "addresses": [
    {
      "street": "123 Main St",
      "city": "Springfield",
      "zipcode": "62701"
    }
  ],
  "active": true
}

The result is a plain dictionary tree with no dataclass instances left in it, ready to pass to json.dumps or any other serializer.

Common mistakes

Using a mutable default directly. Writing items: list = [] shares the same list across all instances. Use field(default_factory=list) instead.

Forgetting that field order matters. In init, fields appear in definition order. Fields with defaults must come after fields without defaults, or Python raises TypeError.

Using dataclass when a namedtuple would be simpler. If you need immutability, tuple unpacking, and indexing, namedtuple is lighter. See collections.namedtuple for comparison.

Overriding init without calling post_init. If you write your own init, the generated post_init is not called automatically. Either avoid custom init or call self.post_init() at the end of your implementation.

Rune AI

Rune AI

Key Insights

  • @dataclass generates __init__, __repr__, and __eq__ automatically from type-annotated fields.
  • Use field(default=...) for mutable defaults and field(default_factory=...) for lists, dicts, and sets.
  • Use frozen=True for immutable, hashable dataclasses.
  • Use __post_init__ for validation or derived fields after the generated __init__ runs.
  • Dataclasses support inheritance and can be compared, sorted, and used with type checkers.
  • Use dataclasses.asdict() to convert a dataclass instance to a dictionary.
RunePowered by Rune AI

Frequently Asked Questions

When should I use a dataclass instead of a regular class?

Use a `dataclass` when the class primarily stores data and the boilerplate methods (__init__, __repr__, __eq__) follow predictable patterns. If the class has complex initialization logic, many methods, or is primarily about behavior rather than data, a regular class may be more appropriate.

How do I make a dataclass immutable?

Pass `frozen=True` to the decorator: `@dataclass(frozen=True)`. This makes all fields read-only after initialization. Immutable dataclasses are hashable (if all fields are hashable) and can be used as dictionary keys or set members.

What is the difference between dataclass and namedtuple?

Both create data containers. `namedtuple` is immutable, lighter, and tuple-compatible (indexing, unpacking). `dataclass` is mutable by default, supports type hints, default values, methods, inheritance, and `__post_init__`. Use `dataclass` for most new code; use `namedtuple` when you specifically need tuple behavior.

Conclusion

dataclasses eliminate the repetitive boilerplate of writing init, repr, and eq for data-focused classes. Use @dataclass for models, configuration objects, API responses, and any class where the primary purpose is storing named values. Add frozen=True when you need immutability and hashability.