New in 2026: Master Python for AI, Data Science

ProgrammingPython

Python Dataclasses: The Complete 2026 Guide — From @dataclass to slots, frozen, and __post_init__

Python Dataclasses: The Complete 2026 Guide — From @dataclass to slots, frozen, and __post_init__
Python’s dataclasses — Data Classes Explained

If you’ve ever written a class whose only purpose was to hold structured data — and then spent 30 lines defining `__init__`, `__repr__`, `__eq__`, and field names — Python’s `dataclasses` will feel like a revelation. Added in Python 3.7, they eliminate that boilerplate entirely, reducing what could be 30+ lines down to a few decorators and type hints.

In this tutorial, you will learn to:

  • Understand what problem dataclasses solve and when to use them
  • Create dataclasses with required fields, default values, and default factories
  • Control field behavior with `repr`, `compare`, and `hash` options
  • Make dataclasses immutable with `frozen=True`
  • Optimize memory with `slots=True` (Python 3.10+)
  • Use `__post_init__` for validation and auto-generation
  • Apply dataclasses in real-world patterns: config objects, entities, DTOs

Prerequisites

This article assumes you are comfortable with Python type hints and annotations. If you are new to type annotations, start with the duck typing vs static typing guide first.

The Problem Dataclasses Solve

Writing a simple data-holding class the traditional way requires manual implementation of several dunder methods:

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

    def __eq__(self, other):
        if not isinstance(other, Point):
            return False
        return self.x == other.x and self.y == other.y

p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1)       # Need __repr__ manually
print(p1 == p2)  # Need __eq__ manually

Now the dataclass equivalent:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1)       # Point(x=1.0, y=2.0) — auto __repr__
print(p1 == p2) # True — auto __eq__

The @dataclass decorator introspects the class annotations and auto-generates __init__, __repr__, __eq__, and more — based on the type hints you write anyway.

Basic Usage — Fields, Types, and Defaults

Required Fields

from dataclasses import dataclass

@dataclass
class User:
    name: str
    email: str
    age: int

user = User("Alice", "[email protected]", 30)
print(user)
# User(name='Alice', email='[email protected]', age=30)

Default Values

@dataclass
class Product:
    name: str
    price: float
    in_stock: bool = True  # Default value
    quantity: int = 0

p = Product("Laptop", 999.99)
print(p)  # Product(name='Laptop', price=999.99, in_stock=True, quantity=0)

Default Factory for Mutable Types

You cannot use a mutable object (list, dict, set) as a default — Python would share it across all instances:

# WRONG — shared mutable default!
@dataclass
class BadStudent:
    name: str
    grades: list = []  # BUG — all instances share the same list!

s1 = BadStudent("Alice")
s2 = BadStudent("Bob")
s1.grades.append(95)
print(s2.grades)  # [95] — oops! Bob got Alice's grades!

Use field(default_factory=list) instead:

from dataclasses import dataclass, field

@dataclass
class Student:
    name: str
    grades: list = field(default_factory=list)  # Correct — fresh list per instance

    def average(self):
        return sum(self.grades) / len(self.grades) if self.grades else 0.0

s1 = Student("Alice")
s2 = Student("Bob")
s1.grades.append(95)
s2.grades.extend([88, 92])
print(s1.grades)  # [95]
print(s2.grades)  # [88, 92]
print(s1.average())  # 95.0

Multiple Defaults with field

from dataclasses import dataclass, field

@dataclass
class Event:
    name: str
    timestamp: str = field(default_factory=lambda: "2026-01-01T00:00:00Z")
    tags: set = field(default_factory=set)
    metadata: dict = field(default_factory=dict)

Field Options — Beyond the Basics

repr=True/False — Hide from __repr__

from dataclasses import dataclass, field

@dataclass
class Config:
    username: str
    password: str = field(default="secret")
    api_key: str = field(default="", repr=False)  # Hidden from repr

c = Config("admin", "pass123", "abc123xyz")
print(c)  # Config(username='admin', password='pass123') — api_key hidden
print(c.api_key)  # abc123xyz — still accessible

compare=True/False — Exclude from Equality

@dataclass
class Record:
    id: int
    data: str
    last_modified: str = field(default_factory=str, compare=False)
    # last_modified won't affect equality checks

hash=True/False — Control Hashability

@dataclass(unsafe_hash=True)
class Item:
    name: str
    price: float
    # Hashable even though it's a dataclass (uses hash of all fields)
    # Use unsafe_hash=True when you want hash but NOT frozen=True

@dataclass(frozen=True)  # Immutable
class FrozenItem:
    name: str
    price: float
    # Frozen dataclasses are automatically hashable

frozen=True — Immutable Dataclasses

Frozen dataclasses prevent any modification after creation — useful for value objects, settings, and anything that should be genuinely immutable:

from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinates:
    lat: float
    lon: float

coord = Coordinates(40.7128, -74.0060)
coord.lat = 41.0  # FrozenInstanceError raised!

frozen=True + hash=True — Use in Sets and as Dict Keys

from dataclasses import dataclass

@dataclass(frozen=True)
class Color:
    r: int
    g: int
    b: int

c1 = Color(255, 0, 0)
c2 = Color(255, 0, 0)
c3 = Color(0, 0, 255)

color_set = {c1, c2, c3}
print(len(color_set))  # 2 — c1 and c2 are duplicates (same hash)

color_dict = {c1: "red", c3: "blue"}
print(color_dict[c2])  # "red" — c2 is same as c1

slots=True — Memory Optimization (Python 3.10+)

Python 3.10+ dataclasses support slots=True, which replaces the per-instance __dict__ with a fixed-size slot table — reducing memory by ~40-50% and slightly faster attribute access:

from dataclasses import dataclass

@dataclass(slots=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
# p.z = 3  # AttributeError — slots prevent dynamic attributes
print(p.x)  # Faster access, less memory

Tradeoffs of slots:

  • Cannot add attributes outside the declared fields
  • Cannot use default_factory with slots (use default values instead)
  • Incompatible with multiple inheritance involving regular classes

Post-Initialization Processing

The __post_init__ method runs after the auto-generated __init__:

from dataclasses import dataclass, field

@dataclass
class Person:
    first_name: str
    last_name: str
    email: str = ""

    def __post_init__(self):
        if not self.email:
            # Auto-generate email from name
            self.email = f"{self.first_name.lower()}.{self.last_name.lower()}@example.com"

p = Person("John", "Doe")
print(p.email)  # [email protected] — auto-generated!

Validation with __post_init__

from dataclasses import dataclass

@dataclass
class Account:
    username: str
    balance: float = 0.0

    def __post_init__(self):
        if self.balance < 0:
            raise ValueError("Balance cannot be negative")
        if len(self.username) < 3:
            raise ValueError("Username must be at least 3 characters")

# Account("alice", -100)  # ValueError: Balance cannot be negative

Real-World Dataclass Patterns

Entity with Computed Fields

from dataclasses import dataclass

@dataclass
class Article:
    title: str
    content: str
    author: str
    published_at: str = ""

    @property
    def word_count(self) -> int:
        return len(self.content.split())

    @property
    def reading_time_minutes(self) -> int:
        words = len(self.content.split())
        return max(1, words // 200)

Command-Line Configuration Object

from dataclasses import dataclass, field

@dataclass
class AppConfig:
    debug: bool = False
    host: str = "0.0.0.0"
    port: int = 8000
    workers: int = 4
    log_level: str = "INFO"
    allowed_origins: list = field(default_factory=list)
    database_url: str = "sqlite:///app.db"
    secret_key: str = ""

    @classmethod
    def from_env(cls):
        import os
        return cls(
            debug=os.getenv("DEBUG", "false").lower() == "true",
            port=int(os.getenv("PORT", "8000")),
            secret_key=os.getenv("SECRET_KEY", ""),
        )

config = AppConfig.from_env()

Common Mistakes and Gotchas

  • Mutable default arguments: Never use list, dict, or set as default field values. All instances will share the same object. Always use field(default_factory=list).
  • Mutable default in dataclass without field: Even inside a dataclass, grades: list = [] is still a shared mutable default. Use field(default_factory=list).
  • slots=True + default_factory: Cannot use default_factory with slots=True. Use plain default values instead, or avoid slots for that field.
  • forgetting __post_init__ runs after every init: __post_init__ runs on every object creation including from generated __init__, not just direct instantiation. Ensure idempotent behavior.
  • Dataclasses are not frozen by default: Unlike namedtuples, dataclasses are mutable by default. Use frozen=True for immutability.

Summary

OptionWhat It Does
@dataclassAuto-generates __init__, __repr__, __eq__
field(default_factory=list)Fresh mutable default per instance
frozen=TrueImmutable — raises FrozenInstanceError on modification
slots=TrueMemory optimization — no per-instance __dict__ (Python 3.10+)
repr=FalseExclude field from auto-generated __repr__
compare=FalseExclude field from auto-generated __eq__
__post_init__Run validation/setup after __init__

dataclasses are the go-to choice for any class whose primary purpose is holding structured data. They eliminate boilerplate, integrate cleanly with type hints, and have enough options to handle most real-world data modeling needs without reaching for third-party libraries.

Summary and Next Steps

You now understand how @dataclass eliminates boilerplate, how to handle mutable defaults safely with field(default_factory=), and how to use frozen=True for immutable value objects. You have seen practical patterns for entities, configuration, and validation via __post_init__.

If you want to go deeper, here are related posts worth reading:

Further Reading

Related posts
Python

Pydantic Agent Basics: A Complete 2026 Tutorial

ProgrammingPython

Production-Ready MCP Servers — Security, Testing & Deployment

ProgrammingPython

Build Your First MCP Server with Python SDK — Fundamentals

ProgrammingPython

Connect FastAPI to MCP — Two Integration Patterns

Leave a Reply