Dataclasses and Declarative Models
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.pyOutput:
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.pyOutput:
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
- Create a
@dataclass(slots=True, frozen=True)namedMetricRecordwith fieldstimestamp,metric_name, andvalue. Verify that attempting to changevalueraisesFrozenInstanceError. - Write a dataclass
DatabaseConnectionSpecwheredatabase_urlis automatically constructed in__post_init__fromhost,port, anddatabase_name. - Demonstrate why
@dataclass class Bad: items: list = []causes aValueErrorat class definition time. - Implement a dataclass with
order=Truethat automatically generates comparison operators based on a specificpriorityfield.
Further reading
- PEP 557: Data Classes.
- Python Standard Library:
dataclassesdocumentation. - Python Language Reference:
__slots__optimization.