python dataclasses exist to eliminate a specific, extremely common kind of boilerplate: writing a class whose entire job is holding a handful of structured fields, but still requiring you to manually write __init__, __repr__, and __eq__ by hand every single time. The @dataclass decorator generates all of that automatically, based purely on type-annotated field declarations.
Introduction: The Boilerplate Problem Dataclasses Solve
The pain point
Consider a plain class meant to hold nothing more than a few related fields:
class Point:
def __init__(self, x, y):
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 NotImplemented
return self.x == other.x and self.y == other.yThat's a genuine amount of boilerplate — __init__, __repr__, and __eq__, all hand-written — for a class that conceptually holds nothing more than two related values.
What @dataclass does
@dataclass, added in Python 3.7, generates exactly this boilerplate automatically, based on type-annotated field declarations directly in the class body:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: intBefore and after, side by side
# Before — hand-written, ~10 lines for two fields
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})"
def __eq__(self, other):
return isinstance(other, Point) and self.x == other.x and self.y == other.y
# After — @dataclass, same behavior, 3 lines
@dataclass
class Point:
x: int
y: intp1 = Point(1, 2)
p2 = Point(1, 2)
print(p1) # Point(x=1, y=2) — readable __repr__, generated automatically
print(p1 == p2) # True — value-based equality, generated automaticallyBoth versions behave identically from the outside — @dataclass just eliminates the manual work of writing the equivalent dunder methods yourself.
Basic Fields, Defaults, and the Generated Methods
Declaring fields
Fields are declared with type annotations directly in the class body — no __init__ needs to be written manually at all:
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int
book = Book("Dune", "Frank Herbert", 412)
print(book.title, book.pages) # Dune 412Adding default values
Defaults work exactly the same way as default function arguments, covered in the earlier function arguments article:
@dataclass
class Book:
title: str
author: str
year: int = 1965
book = Book("Dune", "Frank Herbert")
print(book.year) # 1965 — falls back to the defaultWhat gets generated automatically
Three things, purely from the field declarations:
__init__— accepting each field as a parameter, in the order declared, with defaults respected.A readable
__repr__— showing the class name and every field's current value, exactly likePoint(x=1, y=2)above.Value-based
__eq__— comparing instances field by field, rather than Python's default identity-based comparison (which, as covered in the earlier magic methods article, only checks whether two variables point to the exact same object):
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # True — value comparison, not identityAn important caveat: annotations aren't enforced
Type annotations are required syntactically to define a dataclass field at all — x: int needs that annotation to be recognized as a field — but, exactly as covered in the earlier docstrings and function annotations article, they're not enforced at runtime by Python itself:
@dataclass
class Point:
x: int
y: int
p = Point("hello", "world") # runs fine — no error, despite the type hints
print(p) # Point(x='hello', y='world')If you genuinely need runtime type validation, a static type checker like Mypy (run separately, before your program executes) remains the correct tool — @dataclass itself provides zero runtime type checking, purely structural boilerplate generation based on the annotations you provide.
Mutable Defaults with field(default_factory=...)
The problem, and how dataclasses actually prevent it
You've seen the mutable default argument trap several times throughout this series — writing items: list = [] as a dataclass field would create exactly that same shared-object bug. But dataclasses actually go a step further than a plain function: they actively detect this and raise an error at class-definition time, rather than letting the bug slip through silently:
@dataclass
class ShoppingCart:
items: list = []
# ValueError: mutable default <class 'list'> for field items is not allowed:
# use default_factoryThis is a genuinely helpful safety net — the exact bug that would otherwise silently corrupt shared state across every instance (as shown in the earlier classes article) is caught immediately, loudly, before your program even finishes defining the class.
The fix: default_factory
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
items: list = field(default_factory=list)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.items.append("apple")
print(cart1.items) # ['apple']
print(cart2.items) # [] — correctly independentfield(default_factory=list) tells the dataclass to call list() fresh, once per instance, at creation time — giving each object its own genuinely independent list, rather than sharing one across every instance. default_factory accepts any zero-argument callable — list, dict, set, or even a custom function you've defined yourself.
Other useful field() options
repr=False — excludes a field from the generated __repr__, useful for hiding sensitive data like passwords from being accidentally printed or logged:
@dataclass
class User:
username: str
password: str = field(repr=False)
user = User("alex99", "secret123")
print(user) # User(username='alex99') — password is excluded from the outputcompare=False — excludes a field from the generated __eq__, useful when a field shouldn't factor into whether two instances are considered "equal":
@dataclass
class Event:
name: str
timestamp: str = field(compare=False)
e1 = Event("Launch", "10:00 AM")
e2 = Event("Launch", "10:05 AM")
print(e1 == e2) # True — timestamp is excluded from the comparison4. Validation and Derived Fields with post_init
What post_init does
__post_init__ is a method that runs automatically immediately after the generated __init__ finishes — the natural place for validation logic, or for computing a field derived from other fields already set.
Validation example
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
def __post_init__(self):
if self.width <= 0 or self.height <= 0:
raise ValueError("Width and height must be positive")
rect = Rectangle(4, 5) # fine
bad_rect = Rectangle(-1, 5)
# ValueError: Width and height must be positiveComputing a derived field
from dataclasses import dataclass, field
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
self.area = self.width * self.height
rect = Rectangle(4, 5)
print(rect.area) # 20 — computed automatically, not passed infield(init=False) excludes area from the generated __init__'s parameter list entirely — you can't (and shouldn't) pass it in directly when constructing a Rectangle, since __post_init__ computes it correctly from width and height right after construction.
A brief mention of InitVar
Sometimes you need a value passed into __post_init__ for use in some calculation, but you don't want it stored as an actual instance attribute afterward. InitVar handles exactly this case:
from dataclasses import dataclass, InitVar
@dataclass
class Rectangle:
width: float
height: float
scale: InitVar[float] = 1.0
def __post_init__(self, scale):
self.width *= scale
self.height *= scale
rect = Rectangle(4, 5, scale=2.0)
print(rect.width, rect.height) # 8.0 10.0
print(hasattr(rect, "scale")) # False — scale was never stored as an attributescale gets passed into __init__ and forwarded to __post_init__, where it's used to adjust width and height — but it's never actually kept around as a stored attribute afterward, exactly as intended for a value that's only relevant during construction itself.
Immutability, Memory, and When to Use Dataclasses
frozen=True for read-only instances
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
p.x = 100
# dataclasses.FrozenInstanceError: cannot assign to field 'x'frozen=True makes instances read-only after creation — any attempt to reassign a field raises FrozenInstanceError immediately, mirroring the immutability guarantees covered in the earlier tuples article, but applied to a full dataclass instead.
A caveat worth flagging: frozen=True only prevents reassigning a field directly — it doesn't make the contents of a mutable field immutable, the exact same structure-vs-contents nuance covered in the earlier tuples article:
@dataclass(frozen=True)
class Container:
items: list
c = Container([1, 2, 3])
c.items.append(4) # this still works — the list itself is mutable
print(c.items) # [1, 2, 3, 4]
c.items = []
# FrozenInstanceError — but reassigning the field itself is blockedslots=True for reduced memory usage
Since Python 3.10, @dataclass(slots=True) automatically generates __slots__ for the class, which can meaningfully reduce memory usage — particularly valuable when creating large numbers of instances:
@dataclass(slots=True)
class Point:
x: int
y: intThis achieves the same memory optimization you'd get from manually writing __slots__ = ("x", "y") yourself, without needing to write and keep that list in sync with your field declarations by hand. (Note the functools.cached_property incompatibility with __slots__, mentioned in the earlier @property article, applies here too — a slots=True dataclass can't use cached_property on its fields.)
Comparison to alternatives
namedtuple (covered in the earlier tuples article) is a good fit for simpler, genuinely tuple-like immutable records, where you want positional unpacking and minimal overhead, without needing validation logic, mutability, or the fuller dataclass feature set.
A plain class remains the better choice when a class's behavior — its methods and how it operates on its own data — matters more than the raw data it holds. Dataclasses are specifically optimized for the "structured bag of data" case; a class with substantial custom behavior beyond field storage doesn't gain much from @dataclass, and might read more clearly as a conventional class instead.
Dataclasses hit a genuine sweet spot: structured data with type hints, automatic __init__/__repr__/__eq__ generation, validation via __post_init__, and optional immutability via frozen=True — all built directly into Python's standard library, with zero external dependencies required (unlike the popular third-party attrs library, which offers a broadly similar feature set but requires installing a separate package).