Dataclasses and Declarative Models

Updated

September 7, 2026

Dataclasses and Declarative Models

After reading this chapter, you will master Python’s @dataclass decorator (PEP 557), enforce immutability with frozen=True, optimize memory with slots=True, configure default factories for safe collections, execute validation via __post_init__, and understand the boundary between standard library dataclasses and Pydantic v2.

Mental model

Writing repetitive boilerplate (__init__, __repr__, __eq__, __hash__) for data-holding classes is error-prone. The @dataclass decorator inspects type annotations at class definition time and automatically generates these methods.

Class Source:
  @dataclass(slots=True, frozen=True)
  class ServiceNode:
      host: str
      port: int = 8080

Generated by CPython:
  - __init__(self, host: str, port: int = 8080)
  - __repr__(self) -> "ServiceNode(host=..., port=...)"
  - __eq__(self, other) -> compares tuple of fields
  - __hash__(self) -> computes hash from fields (enabled by frozen=True)
  - __slots__ = ('host', 'port') -> eliminates per-instance __dict__

Memory Architecture: __dict__ vs __slots__

A standard Python object maintains a dynamic dictionary (__dict__) to allow arbitrary attribute additions at runtime. In microservices holding hundreds of thousands of objects (metrics, tokens, edges), __dict__ introduces massive memory overhead:

Standard Instance with __dict__:
  PyObject Header ──▶ __dict__ (PyDictObject: table, keys, hash entries) (~152 bytes)

Dataclass with slots=True:
  PyObject Header ──▶ [ field_0_ptr, field_1_ptr ] (Fixed C-struct offsets) (~56 bytes)

Minimal example

Save as dataclasses_deep_dive.py:

# dataclasses_deep_dive.py
from dataclasses import dataclass, field

@dataclass(slots=True, frozen=True)
class EndpointConfig:
    host: str
    port: int = 443
    tags: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        # Validation hook executed immediately after generated __init__
        if not (1 <= self.port <= 65535):
            raise ValueError(f"Port {self.port} out of range (1-65535)")

def main() -> None:
    ep1 = EndpointConfig("auth.prod.internal", 8443, tags=["prod", "security"])
    ep2 = EndpointConfig("auth.prod.internal", 8443, tags=["prod", "security"])

    print("Representation:", repr(ep1))
    print(f"ep1 == ep2    : {ep1 == ep2}")
    
    # Hashable because frozen=True and tags can be hashed if converted or compared
    print(f"Port accessor  : {ep1.port}")

    # Mutation is strictly forbidden
    try:
        ep1.port = 80  # type: ignore[misc]
    except Exception as err:
        print(f"Mutation rejected: {type(err).__name__}")

if __name__ == "__main__":
    main()

Run via uv run python dataclasses_deep_dive.py:

Representation: EndpointConfig(host='auth.prod.internal', port=8443, tags=['prod', 'security'])
ep1 == ep2    : True
Port accessor  : 8443
Mutation rejected: FrozenInstanceError

Worked examples

Case 1: Mutable Defaults via field(default_factory=...)

Python prevents using a mutable object directly as a dataclass default. You must provide a callable factory:

# safe_defaults.py
from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class AuditRecord:
    actor: str
    action: str
    # DANGEROUS: metadata: dict = {} is rejected by dataclasses!
    # SAFE: Use default_factory
    metadata: dict[str, str] = field(default_factory=dict)
    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())

if __name__ == "__main__":
    rec1 = AuditRecord("admin", "restart_service")
    rec1.metadata["target"] = "worker-01"

    rec2 = AuditRecord("operator", "drain_node")

    print(f"Record 1 metadata: {rec1.metadata}")
    print(f"Record 2 metadata: {rec2.metadata} (Remains isolated and empty)")

Run:

uv run python safe_defaults.py

Output:

Record 1 metadata: {'target': 'worker-01'}
Record 2 metadata: {} (Remains isolated and empty)

Case 2: Memory Benchmark: slots=True vs Standard Class

When handling high-volume data streams (e.g. log events or sensor telemetry), slots=True drastically reduces memory consumption:

# slots_memory_benchmark.py
import sys
from dataclasses import dataclass

class StandardEvent:
    def __init__(self, device_id: str, reading: float) -> None:
        self.device_id = device_id
        self.reading = reading

@dataclass(slots=True)
class SlottedEvent:
    device_id: str
    reading: float

def main() -> None:
    std = StandardEvent("sensor-99", 23.4)
    slotted = SlottedEvent("sensor-99", 23.4)

    # Note: sys.getsizeof on an instance does not include its __dict__ size
    std_total_size = sys.getsizeof(std) + sys.getsizeof(std.__dict__)
    slotted_total_size = sys.getsizeof(slotted)

    print(f"Standard instance total memory: {std_total_size} bytes (has __dict__)")
    print(f"Slotted instance total memory : {slotted_total_size} bytes (no __dict__)")
    print(f"Memory reduction              : {((std_total_size - slotted_total_size) / std_total_size) * 100:.1f}%")

if __name__ == "__main__":
    main()

Run:

uv run python slots_memory_benchmark.py

Output:

Standard instance total memory: 152 bytes (has __dict__)
Slotted instance total memory : 56 bytes (no __dict__)
Memory reduction              : 63.2%

Case 3: Dataclasses vs Pydantic v2: Architectural Boundaries

A common architectural question in modern Python: When to use @dataclass vs Pydantic?

Feature Standard Library @dataclass Pydantic v2 (BaseModel)
Dependency Standard library (zero dependencies) External package (pip install pydantic)
Type Checking Annotations used for code generation only; no runtime enforcement Strict runtime validation & automatic type coercion
Parsing None (requires manual parsing) Built-in JSON/dict deserialization and schema export
Execution Speed Zero overhead; pure Python / C-struct Rust-core (pydantic-core) high speed parsing
Best Used For Internal application state, domain entities, math models Public API boundaries, untrusted JSON input, config loading

Pitfalls

Pitfall 1: Assuming @dataclass Enforces Types at Runtime

Standard dataclasses do not validate types when assigned:

@dataclass
class PortConfig:
    port: int

# Valid syntax; no error is raised despite 'invalid' being a string!
p = PortConfig("invalid")  # type: ignore[arg-type]
print(p.port)  # Prints: "invalid"

If you need runtime type enforcement at system boundaries, use __post_init__ validation or Pydantic.


Exercises

  1. Create a @dataclass(slots=True, frozen=True) named MetricRecord with fields timestamp, metric_name, and value. Verify that attempting to change value raises FrozenInstanceError.
  2. Write a dataclass DatabaseConnectionSpec where database_url is automatically constructed in __post_init__ from host, port, and database_name.
  3. Demonstrate why @dataclass class Bad: items: list = [] causes a ValueError at class definition time.
  4. Implement a dataclass with order=True that automatically generates comparison operators based on a specific priority field.

Further reading

  • PEP 557: Data Classes.
  • Python Standard Library: dataclasses documentation.
  • Python Language Reference: __slots__ optimization.