|$ curl https://forge-ai.dev/api/markdown?path=docs/python/dataclasses
$cat docs/python-—-data-classes.md
updated Recently·14 min read·published

Python — Data Classes

PythonIntermediate
Introduction

Data classes, introduced in Python 3.7 via PEP 557, reduce boilerplate by automatically generating __init__, __repr__, __eq__, and __hash__ from type annotations. They are the idiomatic way to model simple data containers.

Basic Usage

Decorate a class with @dataclass and declare fields with type annotations. The decorator generates __init__, __repr__, __eq__, and optionally __hash__, __lt__, __le__, __gt__, __ge__.

basic.py
Python
1from dataclasses import dataclass
2
3@dataclass
4class Person:
5 name: str
6 age: int
7 email: str = ""
8
9# __init__ is auto-generated:
10p = Person("Alice", 30, "alice@example.com")
11
12# __repr__:
13print(p) # Person(name='Alice', age=30, email='alice@example.com')
14
15# __eq__:
16p2 = Person("Alice", 30, "alice@example.com")
17print(p == p2) # True
18
19# Fields without defaults must come before those with defaults:
20# @dataclass
21# class Bad:
22# a: int = 0
23# b: str # TypeError: non-default argument follows default argument
The field() Function

field() provides fine-grained control over individual fields — mutable defaults, exclusion from __init__, comparison, repr, and hashing.

field.py
Python
1from dataclasses import dataclass, field
2from typing import List
3
4@dataclass
5class Config:
6 name: str
7 version: int = 1
8
9 # Mutable default — must use default_factory
10 tags: List[str] = field(default_factory=list)
11
12 # Exclude from __init__ (computed field)
13 cache: dict = field(default_factory=dict, init=False)
14
15 # Exclude from __repr__
16 secret: str = field(default="", repr=False)
17
18 # Exclude from comparisons
19 metadata: str = field(default="", compare=False)
20
21 # Explicit hash control
22 id: int = field(default=0, hash=False)
23
24 # Keyword-only field (Python 3.10+)
25 debug: bool = field(default=False, kw_only=True)
26
27# kw_only fields must be passed by keyword
28c = Config("app", tags=["a"], debug=True)
29
30# init=False fields are not in __init__
31# c = Config("app", cache={}) # TypeError

info

Always use default_factory for mutable defaults like lists, dicts, or sets. A bare [] as default is shared across all instances — a classic Python gotcha.
Post-Init Validation

Define __post_init__ to run validation or derived-field computation after __init__ completes. This is the standard hook for data-class invariants.

post_init.py
Python
1from dataclasses import dataclass, field
2from datetime import date
3from typing import Optional
4
5@dataclass
6class Employee:
7 name: str
8 birth_year: int
9 employee_id: str = field(init=False)
10 age: int = field(init=False)
11
12 def __post_init__(self):
13 # Validation
14 if self.birth_year < 1900 or self.birth_year > 2100:
15 raise ValueError(f"Invalid birth year: {self.birth_year}")
16
17 # Derived field
18 self.age = date.today().year - self.birth_year
19
20 # Auto-generated ID
21 self.employee_id = f"{self.name.lower().replace(' ', '-')}-{self.birth_year}"
22
23e = Employee("Alice Smith", 1990)
24print(e)
25# Employee(name='Alice Smith', birth_year=1990, employee_id='alice-smith-1990', age=36)
26
27# Validation catches bad data:
28# Employee("Bob", 1800) # ValueError: Invalid birth year: 1800
29
30# Cross-field validation
31@dataclass
32class Order:
33 items: List[str] = field(default_factory=list)
34 shipped: bool = False
35 delivered: bool = False
36
37 def __post_init__(self):
38 if self.delivered and not self.shipped:
39 raise ValueError("Cannot be delivered without being shipped")

best practice

Keep __post_init__ focused on validation and derived values. For complex initialization logic, consider a @classmethod factory instead of overloading the constructor.
Frozen & Slots

frozen=True makes instances immutable — attribute assignment raises FrozenInstanceError. slots=True (Python 3.10+) generates __slots__ for memory efficiency.

frozen.py
Python
1from dataclasses import dataclass
2
3@dataclass(frozen=True)
4class Point:
5 x: float
6 y: float
7
8p = Point(1.0, 2.0)
9# p.x = 3.0 # FrozenInstanceError: cannot assign to field 'x'
10
11# Frozen + __post_init__ work together:
12@dataclass(frozen=True)
13class ImmutableConfig:
14 host: str
15 port: int = 8080
16
17 def __post_init__(self):
18 # Validation is fine even on frozen instances
19 if not 1 <= self.port <= 65535:
20 raise ValueError(f"Invalid port: {self.port}")
21
22# slots=True (Python 3.10+) — no __dict__, faster, less memory
23@dataclass(slots=True)
24class FastPoint:
25 x: float
26 y: float
27
28fp = FastPoint(3, 4)
29# fp.__dict__ # AttributeError: 'FastPoint' object has no attribute '__dict__'
30
31# Comparison: slots vs no slots
32import sys
33
34@dataclass
35class Regular: x: int; y: int # has __dict__ (~120 bytes)
36@dataclass(slots=True)
37class Slotted: x: int; y: int # no __dict__ (~56 bytes)
38
39print(sys.getsizeof(Regular(1, 2))) # 48 (plus __dict__ = ~120 total)
40print(sys.getsizeof(Slotted(1, 2))) # 32
Keyword-Only Fields

Python 3.10+ allows marking all or specific fields as keyword-only, preventing positional misuse. This is especially useful when many fields share the same type.

kw_only.py
Python
1from dataclasses import dataclass, field
2
3# Make all fields keyword-only
4@dataclass(kw_only=True)
5class APIResponse:
6 status: int
7 data: dict
8 message: str = ""
9
10# Must use keyword arguments:
11resp = APIResponse(status=200, data={"key": "val"})
12# APIResponse(status=200, data={'key': 'val'}, message='')
13# resp = APIResponse(200, {"key": "val"}) # TypeError
14
15# Mix positional and keyword-only fields:
16@dataclass
17class User:
18 name: str # positional
19 email: str # positional
20 role: str = field(default="user", kw_only=True) # keyword-only
21
22u = User("Alice", "alice@example.com", role="admin")
23# Positional: name and email must be positional
24# Keyword-only: role must be by keyword
25
26u2 = User("Bob", "bob@example.com")
27print(u2) # User(name='Bob', email='bob@example.com', role='user')
28
29# kw_only also works at the decorator level:
30@dataclass(kw_only=True)
31class Point:
32 x: float
33 y: float
34
35p = Point(x=1.0, y=2.0)

info

kw_only prevents bugs from accidental positional swaps. It pairs well with frozen=True for truly value-safe data classes.
Ordering & Comparison

Setting order=True generates __lt__, __le__, __gt__, and __ge__. Fields are compared in declaration order. Use field(compare=False) to exclude a field.

ordering.py
Python
1from dataclasses import dataclass, field
2
3@dataclass(order=True)
4class Score:
5 value: int
6 name: str = field(compare=False) # not used in ordering
7
8scores = [
9 Score(85, "Alice"),
10 Score(92, "Bob"),
11 Score(75, "Charlie"),
12 Score(92, "Diana"),
13]
14
15scores.sort()
16print(scores)
17# [Score(value=75, name='Charlie'),
18# Score(value=85, name='Alice'),
19# Score(value=92, name='Bob'),
20# Score(value=92, name='Diana')]
21
22# Control field ordering in comparisons:
23@dataclass(order=True)
24class Task:
25 priority: int = field(compare=True) # sorted by priority first
26 due_date: str = field(compare=True) # then by due date
27 title: str = field(compare=False) # not used in ordering
28
29# Hash control — by default dataclasses are unhashable
30# if __eq__ is generated and __hash__ is not defined.
31@dataclass(frozen=True, order=True)
32class HashablePoint:
33 x: float
34 y: float
35
36points_set = {HashablePoint(1, 2), HashablePoint(3, 4)}
37print(points_set) # {HashablePoint(x=1, y=2), HashablePoint(x=3, y=4)}
38
39# Unsafe hash — allow mutation while keeping hash
40@dataclass(unsafe_hash=True)
41class MutableKey:
42 id: int
43 data: str = ""
44 # Warning: mutating after insertion breaks the set/dict
Inheritance

Data classes support single and multiple inheritance. Child classes inherit parent fields and can add their own. The __init__ is composed to accept all fields from the full MRO.

inheritance.py
Python
1from dataclasses import dataclass
2from typing import List
3
4@dataclass
5class Person:
6 name: str
7 email: str = ""
8
9@dataclass
10class Employee(Person):
11 employee_id: str = ""
12 department: str = "engineering"
13
14# __init__ includes all fields — parent first, then child:
15e = Employee("Alice", "alice@example.com", "E001", "ml")
16print(e)
17# Employee(name='Alice', email='alice@example.com',
18# employee_id='E001', department='ml')
19
20# Fields cannot have defaults if a parent field lacks one.
21# This fails:
22# @dataclass
23# class Base:
24# x: str # no default
25
26# @dataclass
27# class Child(Base):
28# y: str = "" # TypeError: non-default follows default
29
30# Workaround: give parent fields defaults too, or reorder.
31
32# Multiple inheritance
33@dataclass
34class HasTimestamps:
35 created_at: str = ""
36 updated_at: str = ""
37
38@dataclass
39class HasArchived:
40 archived: bool = False
41
42@dataclass
43class Document(HasTimestamps, HasArchived):
44 title: str = ""
45 body: str = ""
46
47doc = Document(title="Readme", body="Content", created_at="2024-01-01")
48print(doc)
49# Document(title='Readme', body='Content',
50# created_at='2024-01-01', updated_at='', archived=False)

best practice

Prefer composition over inheritance with data classes. Use a single parent or mixins with no fields of their own to avoid MRO complexity and default-ordering issues.
Serialization

The dataclasses module provides asdict() and astuple() for recursive conversion. For JSON serialization, combine with json.dumps and a custom encoder.

serialization.py
Python
1from dataclasses import dataclass, field, asdict, astuple
2from typing import List, Optional
3import json
4
5@dataclass
6class Address:
7 street: str
8 city: str
9 zip_code: str = ""
10
11@dataclass
12class Person:
13 name: str
14 age: int
15 address: Address
16 tags: List[str] = field(default_factory=list)
17
18p = Person("Alice", 30, Address("123 Main", "NYC"), ["dev", "ml"])
19
20# asdict — recursive dict (deep copy)
21d = asdict(p)
22print(d)
23# {
24# 'name': 'Alice',
25# 'age': 30,
26# 'address': {'street': '123 Main', 'city': 'NYC', 'zip_code': ''},
27# 'tags': ['dev', 'ml']
28# }
29
30# astuple — recursive tuple
31t = astuple(p)
32print(t) # ('Alice', 30, ('123 Main', 'NYC', ''), ['dev', 'ml'])
33
34# JSON serialization
35class DataclassEncoder(json.JSONEncoder):
36 def default(self, obj):
37 if hasattr(obj, "__dataclass_fields__"):
38 return asdict(obj)
39 return super().default(obj)
40
41json_str = json.dumps(p, cls=DataclassEncoder, indent=2)
42print(json_str)
43# {
44# "name": "Alice",
45# "age": 30,
46# "address": {"street": "123 Main", "city": "NYC", "zip_code": ""},
47# "tags": ["dev", "ml"]
48# }
49
50# Deserialize from dict
51def from_dict(cls, data: dict):
52 field_types = {f.name: f.type for f in fields(cls)}
53 for name, value in data.items():
54 if hasattr(value, "__dataclass_fields__"):
55 data[name] = from_dict(value.__class__, value)
56 return cls(**data)
57
58from dataclasses import fields
59
60restored = from_dict(Person, d)
61print(restored) # Person(name='Alice', age=30, ...)
Dataclasses vs Alternatives

Data classes occupy a sweet spot. Lightweight alternatives include namedtuple (immutable, no type hints) and attrs (richer validation, pre-dataclass). For heavy lifting with validation, Pydantic provides runtime type checking and JSON schema generation.

comparison.py
Python
1# ─── namedtuple (built-in, immutable, no type hints) ───
2from collections import namedtuple
3
4PointNT = namedtuple("PointNT", ["x", "y"])
5p_nt = PointNT(1, 2)
6print(p_nt.x, p_nt.y) # 1 2
7# p_nt.x = 5 # AttributeError: can't set attribute
8
9# ─── TypedDict (dict-like, structural typing, Python 3.8+) ───
10from typing import TypedDict
11
12class PointTD(TypedDict):
13 x: float
14 y: float
15
16p_td: PointTD = {"x": 1.0, "y": 2.0} # still a plain dict
17
18# ─── attrs (third-party, feature-rich, pre-3.7) ───
19import attr
20
21@attr.s(auto_attribs=True)
22class PointAttrs:
23 x: float
24 y: float
25
26p_a = PointAttrs(1.0, 2.0)
27
28# ─── Pydantic (runtime validation, JSON Schema) ───
29from pydantic import BaseModel
30
31class PointPyd(BaseModel):
32 x: float
33 y: float
34
35p_p = PointPyd(x="1.5", y=2) # coerces types!
36print(p_p.model_dump()) # {'x': 1.5, 'y': 2.0}
37print(p_p.model_json_schema())
38# {
39# "properties": {"x": {"title": "X", "type": "number"}, ...},
40# "title": "PointPyd", "type": "object"
41# }
42
43# Summary Table (mental model):
44# Feature | namedtuple | @dataclass | attrs | Pydantic
45# ──────────────────┼────────────┼────────────┼───────┼─────────
46# Immutable | yes | frozen= | yes | Config
47# Type hints | no | yes | yes | yes
48# Validation | no | __post_ | validators | yes (runtime)
49# JSON Schema | no | no | no | yes
50# Performance | fast | fast | fast | slower
51# Dependencies | stdlib | stdlib | attrs | pydantic

info

Start with @dataclass by default. Reach for Pydantic when you need runtime type coercion, JSON Schema output, or robust validation. Use namedtuple for simple immutable lightweight records in performance-sensitive code.
Best Practices

1. Use frozen=True for value objects — makes them hashable, safe to use as dict keys, and prevents accidental mutation.

2. Prefer field(default_factory=...) over mutable defaults — a bare [] is evaluated once at class definition and shared.

3. Keep data classes focused on data. Complex business logic belongs in separate service/domain classes, not in __post_init__.

4. Use kw_only=True when a class has many fields of the same type — prevents positional swap bugs.

5. Enable slots=True (3.10+) for memory-sensitive applications like data pipelines or ML feature stores.

6. Mark sensitive fields with repr=False to avoid leaking secrets in logs or tracebacks.

7. Write __post_init__ defensively — validate early, fail fast. A corrupted data class propagates errors silently.

8. For JSON round-tripping, write a reusable from_dict classmethod or use a library like dataclasses-json.

$Blueprint — Engineering Documentation·Section ID: PYTHON-DC·Revision: 1.0