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
Page Contents
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_factorywith 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, orsetas default field values. All instances will share the same object. Always usefield(default_factory=list). - Mutable default in dataclass without field: Even inside a dataclass,
grades: list = []is still a shared mutable default. Usefield(default_factory=list). - slots=True + default_factory: Cannot use
default_factorywithslots=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=Truefor immutability.
Summary
| Option | What It Does |
|---|---|
@dataclass | Auto-generates __init__, __repr__, __eq__ |
field(default_factory=list) | Fresh mutable default per instance |
frozen=True | Immutable — raises FrozenInstanceError on modification |
slots=True | Memory optimization — no per-instance __dict__ (Python 3.10+) |
repr=False | Exclude field from auto-generated __repr__ |
compare=False | Exclude 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:
- Python Type Hints and Annotations — dataclasses pair naturally with type hints
- Python’s Duck Typing vs Static Typing — when dataclasses shine vs when to use alternatives
- Python’s New Type Checker ty — how modern type checkers handle dataclasses

