Skip to content
Codeloom
Python

Advanced Python Dataclasses: Beyond the Basics

Go beyond basic dataclass usage with post-init processing, field factories, inheritance, frozen instances, and custom serialization.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Post-init processing and computed fields
  • Field factories for mutable defaults
  • Frozen and ordered dataclasses
  • Inheritance patterns and serialization

Prerequisites

  • Basic Python dataclass usage
  • Understanding of type hints
  • Familiarity with Python classes

Beyond @dataclass

Most tutorials cover the basics: slap @dataclass on a class, add some type-annotated fields, and get __init__, __repr__, and __eq__ for free. But dataclasses offer much more. This article covers the advanced features that make them suitable for production code.

Field Factories: Handling Mutable Defaults

A common mistake with regular classes is using mutable default arguments. Dataclasses solve this with field(default_factory=...):

from dataclasses import dataclass, field

@dataclass
class Config:
    name: str
    tags: list[str] = field(default_factory=list)
    metadata: dict[str, str] = field(default_factory=dict)
    settings: dict = field(default_factory=lambda: {
        "debug": False,
        "log_level": "INFO",
        "retries": 3,
    })

c1 = Config("app1")
c2 = Config("app2")
c1.tags.append("production")
print(c2.tags)  # [] -- each instance gets its own list

The factory function is called once per instance, ensuring no shared mutable state.

Post-Init Processing with post_init

The __post_init__ method runs after the auto-generated __init__. Use it for validation, computed fields, and transformations:

from dataclasses import dataclass, field

@dataclass
class Temperature:
    celsius: float
    fahrenheit: float = field(init=False)
    kelvin: float = field(init=False)

    def __post_init__(self):
        if self.celsius < -273.15:
            raise ValueError("Temperature below absolute zero")
        self.fahrenheit = self.celsius * 9/5 + 32
        self.kelvin = self.celsius + 273.15

t = Temperature(100)
print(t.fahrenheit)  # 212.0
print(t.kelvin)      # 373.15

Fields with init=False are excluded from the constructor but included in __repr__ and __eq__.

InitVar: Init-Only Fields

Sometimes you need a parameter in __init__ that should not become an instance attribute:

from dataclasses import dataclass, field, InitVar

@dataclass
class User:
    username: str
    email: str
    password: InitVar[str]  # Not stored as attribute
    password_hash: str = field(init=False)

    def __post_init__(self, password: str):
        import hashlib
        self.password_hash = hashlib.sha256(password.encode()).hexdigest()

user = User("alice", "alice@example.com", "secret123")
print(user.password_hash)   # sha256 hash
# print(user.password)      # AttributeError -- not stored
print(user)                  # User(username='alice', email='alice@example.com', password_hash='...')

InitVar fields are passed to __post_init__ as arguments but do not become attributes or appear in __repr__.

Frozen Dataclasses: Immutable Objects

Adding frozen=True makes instances immutable:

from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    latitude: float
    longitude: float

    @property
    def as_tuple(self):
        return (self.latitude, self.longitude)

point = Coordinate(40.7128, -74.0060)
# point.latitude = 0  # FrozenInstanceError!

# Frozen dataclasses are hashable (can be used in sets and as dict keys)
locations = {point: "New York City"}
print(locations[Coordinate(40.7128, -74.0060)])  # "New York City"

Frozen dataclasses automatically get a __hash__ method, making them usable as dictionary keys and set members.

Ordering with order=True

from dataclasses import dataclass

@dataclass(order=True)
class Version:
    major: int
    minor: int
    patch: int

    def __str__(self):
        return f"{self.major}.{self.minor}.{self.patch}"

versions = [Version(2, 0, 1), Version(1, 9, 0), Version(2, 0, 0)]
print(sorted(versions))
# [Version(major=1, minor=9, patch=0), Version(major=2, minor=0, patch=0), Version(major=2, minor=0, patch=1)]

Comparison is done field by field in declaration order. Use field(compare=False) to exclude fields from comparisons:

from dataclasses import dataclass, field

@dataclass(order=True)
class Task:
    priority: int
    name: str = field(compare=False)
    description: str = field(compare=False, repr=False)

tasks = [Task(3, "Low"), Task(1, "High"), Task(2, "Medium")]
print(sorted(tasks))
# Sorted by priority only

Inheritance

Dataclasses support inheritance, but field ordering matters:

from dataclasses import dataclass, field

@dataclass
class Base:
    x: int
    y: int = 0

@dataclass
class Child(Base):
    z: int = 0     # Must have a default since parent has defaults
    label: str = ""

c = Child(x=1, y=2, z=3, label="point")
print(c)  # Child(x=1, y=2, z=3, label='point')

Fields from the parent come first, followed by child fields. If a parent field has a default, all subsequent fields (including child fields) must also have defaults.

Custom Serialization

Dataclasses do not provide built-in serialization, but they make it easy:

from dataclasses import dataclass, field, asdict, astuple
from datetime import datetime
import json

@dataclass
class Event:
    name: str
    timestamp: datetime
    attendees: list[str] = field(default_factory=list)

    def to_dict(self):
        """Custom serialization with datetime handling."""
        data = asdict(self)
        data['timestamp'] = self.timestamp.isoformat()
        return data

    @classmethod
    def from_dict(cls, data):
        data = data.copy()
        data['timestamp'] = datetime.fromisoformat(data['timestamp'])
        return cls(**data)

    def to_json(self):
        return json.dumps(self.to_dict())

    @classmethod
    def from_json(cls, json_str):
        return cls.from_dict(json.loads(json_str))

event = Event("PyCon", datetime(2026, 7, 1), ["Alice", "Bob"])
json_str = event.to_json()
restored = Event.from_json(json_str)
print(restored)

asdict recursively converts the dataclass to a dictionary. astuple converts it to a tuple. Both handle nested dataclasses.

Slots with Dataclasses (Python 3.10+)

Combine the memory efficiency of __slots__ with dataclass convenience:

from dataclasses import dataclass

@dataclass(slots=True)
class Particle:
    x: float
    y: float
    z: float
    mass: float

# Uses ~4x less memory per instance than without slots
particles = [Particle(i, i+1, i+2, 1.0) for i in range(1_000_000)]

match_args and kw_only (Python 3.10+)

from dataclasses import dataclass, field

@dataclass(kw_only=True)
class APIRequest:
    """All fields must be passed as keyword arguments."""
    method: str
    url: str
    headers: dict = field(default_factory=dict)
    body: str | None = None

# Must use keyword arguments
req = APIRequest(method="GET", url="https://api.example.com")
# APIRequest("GET", "https://...")  # TypeError!

You can also make only some fields keyword-only:

from dataclasses import dataclass, field, KW_ONLY

@dataclass
class Query:
    table: str            # Positional
    _: KW_ONLY            # Everything after this is keyword-only
    where: str = ""
    limit: int = 100
    offset: int = 0

q = Query("users", where="active=true", limit=50)

Dataclass vs NamedTuple vs Regular Class

FeaturedataclassNamedTupleRegular class
MutableYes (default)NoYes
InheritanceYesLimitedYes
Default valuesYesYesYes
Type hintsRequiredRequiredOptional
__slots__OptionalBuilt-inOptional
CustomizationHighLowFull

Use dataclasses for mutable data containers with behavior. Use NamedTuple for simple immutable records. Use regular classes when you need full control over initialization or have complex inheritance.

Practical Pattern: Builder with Dataclass

from dataclasses import dataclass, field
from copy import deepcopy

@dataclass
class Pipeline:
    steps: list[str] = field(default_factory=list)
    config: dict = field(default_factory=dict)

    def add_step(self, step: str) -> "Pipeline":
        new = deepcopy(self)
        new.steps.append(step)
        return new

    def set_config(self, key: str, value) -> "Pipeline":
        new = deepcopy(self)
        new.config[key] = value
        return new

pipeline = (
    Pipeline()
    .add_step("extract")
    .add_step("transform")
    .add_step("load")
    .set_config("batch_size", 1000)
)
print(pipeline)

Wrapping Up

Python dataclasses are far more than auto-generated __init__ methods. With post-init processing, frozen instances, field factories, slots support, and keyword-only fields, they handle the vast majority of data container needs. Learn these advanced features and you will write less boilerplate while producing more robust code.