Python — Data Classes
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.
Decorate a class with @dataclass and declare fields with type annotations. The decorator generates __init__, __repr__, __eq__, and optionally __hash__, __lt__, __le__, __gt__, __ge__.
| 1 | from dataclasses import dataclass |
| 2 | |
| 3 | @dataclass |
| 4 | class Person: |
| 5 | name: str |
| 6 | age: int |
| 7 | email: str = "" |
| 8 | |
| 9 | # __init__ is auto-generated: |
| 10 | p = Person("Alice", 30, "alice@example.com") |
| 11 | |
| 12 | # __repr__: |
| 13 | print(p) # Person(name='Alice', age=30, email='alice@example.com') |
| 14 | |
| 15 | # __eq__: |
| 16 | p2 = Person("Alice", 30, "alice@example.com") |
| 17 | print(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 |
field() provides fine-grained control over individual fields — mutable defaults, exclusion from __init__, comparison, repr, and hashing.
| 1 | from dataclasses import dataclass, field |
| 2 | from typing import List |
| 3 | |
| 4 | @dataclass |
| 5 | class 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 |
| 28 | c = Config("app", tags=["a"], debug=True) |
| 29 | |
| 30 | # init=False fields are not in __init__ |
| 31 | # c = Config("app", cache={}) # TypeError |
info
Define __post_init__ to run validation or derived-field computation after __init__ completes. This is the standard hook for data-class invariants.
| 1 | from dataclasses import dataclass, field |
| 2 | from datetime import date |
| 3 | from typing import Optional |
| 4 | |
| 5 | @dataclass |
| 6 | class 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 | |
| 23 | e = Employee("Alice Smith", 1990) |
| 24 | print(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 |
| 32 | class 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
frozen=True makes instances immutable — attribute assignment raises FrozenInstanceError. slots=True (Python 3.10+) generates __slots__ for memory efficiency.
| 1 | from dataclasses import dataclass |
| 2 | |
| 3 | @dataclass(frozen=True) |
| 4 | class Point: |
| 5 | x: float |
| 6 | y: float |
| 7 | |
| 8 | p = 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) |
| 13 | class 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) |
| 24 | class FastPoint: |
| 25 | x: float |
| 26 | y: float |
| 27 | |
| 28 | fp = FastPoint(3, 4) |
| 29 | # fp.__dict__ # AttributeError: 'FastPoint' object has no attribute '__dict__' |
| 30 | |
| 31 | # Comparison: slots vs no slots |
| 32 | import sys |
| 33 | |
| 34 | @dataclass |
| 35 | class Regular: x: int; y: int # has __dict__ (~120 bytes) |
| 36 | @dataclass(slots=True) |
| 37 | class Slotted: x: int; y: int # no __dict__ (~56 bytes) |
| 38 | |
| 39 | print(sys.getsizeof(Regular(1, 2))) # 48 (plus __dict__ = ~120 total) |
| 40 | print(sys.getsizeof(Slotted(1, 2))) # 32 |
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.
| 1 | from dataclasses import dataclass, field |
| 2 | |
| 3 | # Make all fields keyword-only |
| 4 | @dataclass(kw_only=True) |
| 5 | class APIResponse: |
| 6 | status: int |
| 7 | data: dict |
| 8 | message: str = "" |
| 9 | |
| 10 | # Must use keyword arguments: |
| 11 | resp = 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 |
| 17 | class User: |
| 18 | name: str # positional |
| 19 | email: str # positional |
| 20 | role: str = field(default="user", kw_only=True) # keyword-only |
| 21 | |
| 22 | u = User("Alice", "alice@example.com", role="admin") |
| 23 | # Positional: name and email must be positional |
| 24 | # Keyword-only: role must be by keyword |
| 25 | |
| 26 | u2 = User("Bob", "bob@example.com") |
| 27 | print(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) |
| 31 | class Point: |
| 32 | x: float |
| 33 | y: float |
| 34 | |
| 35 | p = Point(x=1.0, y=2.0) |
info
Setting order=True generates __lt__, __le__, __gt__, and __ge__. Fields are compared in declaration order. Use field(compare=False) to exclude a field.
| 1 | from dataclasses import dataclass, field |
| 2 | |
| 3 | @dataclass(order=True) |
| 4 | class Score: |
| 5 | value: int |
| 6 | name: str = field(compare=False) # not used in ordering |
| 7 | |
| 8 | scores = [ |
| 9 | Score(85, "Alice"), |
| 10 | Score(92, "Bob"), |
| 11 | Score(75, "Charlie"), |
| 12 | Score(92, "Diana"), |
| 13 | ] |
| 14 | |
| 15 | scores.sort() |
| 16 | print(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) |
| 24 | class 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) |
| 32 | class HashablePoint: |
| 33 | x: float |
| 34 | y: float |
| 35 | |
| 36 | points_set = {HashablePoint(1, 2), HashablePoint(3, 4)} |
| 37 | print(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) |
| 41 | class MutableKey: |
| 42 | id: int |
| 43 | data: str = "" |
| 44 | # Warning: mutating after insertion breaks the set/dict |
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.
| 1 | from dataclasses import dataclass |
| 2 | from typing import List |
| 3 | |
| 4 | @dataclass |
| 5 | class Person: |
| 6 | name: str |
| 7 | email: str = "" |
| 8 | |
| 9 | @dataclass |
| 10 | class Employee(Person): |
| 11 | employee_id: str = "" |
| 12 | department: str = "engineering" |
| 13 | |
| 14 | # __init__ includes all fields — parent first, then child: |
| 15 | e = Employee("Alice", "alice@example.com", "E001", "ml") |
| 16 | print(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 |
| 34 | class HasTimestamps: |
| 35 | created_at: str = "" |
| 36 | updated_at: str = "" |
| 37 | |
| 38 | @dataclass |
| 39 | class HasArchived: |
| 40 | archived: bool = False |
| 41 | |
| 42 | @dataclass |
| 43 | class Document(HasTimestamps, HasArchived): |
| 44 | title: str = "" |
| 45 | body: str = "" |
| 46 | |
| 47 | doc = Document(title="Readme", body="Content", created_at="2024-01-01") |
| 48 | print(doc) |
| 49 | # Document(title='Readme', body='Content', |
| 50 | # created_at='2024-01-01', updated_at='', archived=False) |
best practice
The dataclasses module provides asdict() and astuple() for recursive conversion. For JSON serialization, combine with json.dumps and a custom encoder.
| 1 | from dataclasses import dataclass, field, asdict, astuple |
| 2 | from typing import List, Optional |
| 3 | import json |
| 4 | |
| 5 | @dataclass |
| 6 | class Address: |
| 7 | street: str |
| 8 | city: str |
| 9 | zip_code: str = "" |
| 10 | |
| 11 | @dataclass |
| 12 | class Person: |
| 13 | name: str |
| 14 | age: int |
| 15 | address: Address |
| 16 | tags: List[str] = field(default_factory=list) |
| 17 | |
| 18 | p = Person("Alice", 30, Address("123 Main", "NYC"), ["dev", "ml"]) |
| 19 | |
| 20 | # asdict — recursive dict (deep copy) |
| 21 | d = asdict(p) |
| 22 | print(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 |
| 31 | t = astuple(p) |
| 32 | print(t) # ('Alice', 30, ('123 Main', 'NYC', ''), ['dev', 'ml']) |
| 33 | |
| 34 | # JSON serialization |
| 35 | class DataclassEncoder(json.JSONEncoder): |
| 36 | def default(self, obj): |
| 37 | if hasattr(obj, "__dataclass_fields__"): |
| 38 | return asdict(obj) |
| 39 | return super().default(obj) |
| 40 | |
| 41 | json_str = json.dumps(p, cls=DataclassEncoder, indent=2) |
| 42 | print(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 |
| 51 | def 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 | |
| 58 | from dataclasses import fields |
| 59 | |
| 60 | restored = from_dict(Person, d) |
| 61 | print(restored) # Person(name='Alice', age=30, ...) |
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.
| 1 | # ─── namedtuple (built-in, immutable, no type hints) ─── |
| 2 | from collections import namedtuple |
| 3 | |
| 4 | PointNT = namedtuple("PointNT", ["x", "y"]) |
| 5 | p_nt = PointNT(1, 2) |
| 6 | print(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+) ─── |
| 10 | from typing import TypedDict |
| 11 | |
| 12 | class PointTD(TypedDict): |
| 13 | x: float |
| 14 | y: float |
| 15 | |
| 16 | p_td: PointTD = {"x": 1.0, "y": 2.0} # still a plain dict |
| 17 | |
| 18 | # ─── attrs (third-party, feature-rich, pre-3.7) ─── |
| 19 | import attr |
| 20 | |
| 21 | @attr.s(auto_attribs=True) |
| 22 | class PointAttrs: |
| 23 | x: float |
| 24 | y: float |
| 25 | |
| 26 | p_a = PointAttrs(1.0, 2.0) |
| 27 | |
| 28 | # ─── Pydantic (runtime validation, JSON Schema) ─── |
| 29 | from pydantic import BaseModel |
| 30 | |
| 31 | class PointPyd(BaseModel): |
| 32 | x: float |
| 33 | y: float |
| 34 | |
| 35 | p_p = PointPyd(x="1.5", y=2) # coerces types! |
| 36 | print(p_p.model_dump()) # {'x': 1.5, 'y': 2.0} |
| 37 | print(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
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.