A dataclass is a class decorated with @dataclass from the dataclasses module (PEP 557, Python 3.7+). The decorator reads the annotated fields of the class and generates boilerplate methods automatically, including __init__, __repr__, and __eq__.
Classes without the boilerplate
What you’ll learn: How @dataclass auto-generates __init__, __repr__, and __eq__ from annotated fields, and how to give fields default values. The modern default for any class that mainly holds data.
How to read this tab: Rewrite one of your existing simple classes as a dataclass. Watch the boilerplate disappear — that is the lesson.
A dataclass writes the tedious parts of a class for you. You declare what fields the class has; Python generates the constructor, a readable printout, and equality comparison automatically. Less code, fewer bugs.
Annotations are how the decorator finds fields. x: int is a dataclass field. x = 5 without an annotation is a plain class attribute the decorator ignores. This is the single most important rule — no annotation, no field.
The problem dataclasses solve
Writing a simple data-holding class the traditional way involves a lot of repetition. The @dataclass decorator eliminates it.
# WITHOUT dataclass — manual boilerplate
class PointOld:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"PointOld(x={self.x}, y={self.y})"
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
# WITH dataclass — same behaviour, far less code
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0) ← generated __repr__
print(p == Point(1.0, 2.0)) # True ← generated __eq__
print(p.x, p.y) # 1.0 2.0A dataclass field must have a type annotation — that is how the decorator finds it. x: float is a field. x = 5 without an annotation is a plain class attribute and is ignored by the dataclass machinery. If you don't want to constrain the type, annotate with typing.Any.
Same ordering rule as function parameters: fields without defaults must come before fields with defaults. name: str then age: int = 0 works; the reverse raises TypeError at class definition.
Fields and default values
from dataclasses import dataclass
@dataclass
class User:
name: str
age: int = 0 # default value
active: bool = True # default value
role: str = "member"
u1 = User("Priya") # uses defaults
print(u1) # User(name='Priya', age=0, active=True, role='member')
u2 = User("Arjun", 30, role="admin")
print(u2) # User(name='Arjun', age=30, active=True, role='admin')
# Fields WITHOUT defaults must come before fields WITH defaults
# (same rule as function parameters)
# @dataclass
# class Bad:
# x: int = 0
# y: int # TypeError — non-default after default✅ Beginner tab complete
- I can write a @dataclass with annotated fields and no manual __init__
- I know fields must have type annotations to be recognised
- I can give fields default values (non-default fields must come first)
- I know the generated __repr__ and __eq__ work automatically
frozen, order, slots, field(), __post_init__
What you’ll learn: The decorator options (frozen for immutability, order for comparisons, slots for memory), field(default_factory=...) for mutable defaults, and __post_init__ for validation.
How to read this tab: The mutable-default rule is critical: never use list/dict directly as a default. Learn field(default_factory=list).
frozen=True gives you immutability AND hashability. A frozen dataclass can be a dict key or set member because Python generates __hash__ for it. Use it for value objects like coordinates, money, or configuration that should never change after creation.
Decorator options
The @dataclass decorator accepts arguments that control what it generates. The most useful are frozen (immutability), order (comparison operators), and slots (memory optimisation).
from dataclasses import dataclass
# frozen=True — immutable, and hashable (usable as dict key / in a set)
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(19.07, 72.87)
# c.lat = 0 # raises FrozenInstanceError
locations = {c: "Mumbai"} # works — frozen dataclasses are hashable
# order=True — generates __lt__, __le__, __gt__, __ge__
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
versions = [Version(1, 2, 0), Version(1, 0, 5), Version(2, 0, 0)]
print(sorted(versions)) # sorted by (major, minor, patch) tuple order
# [Version(1,0,5), Version(1,2,0), Version(2,0,0)]
# slots=True (Python 3.10+) — less memory, faster attribute access
@dataclass(slots=True)
class Particle:
x: float
y: float
z: float
# No per-instance __dict__ — significant memory savings at scaleNever use a mutable default directly — Python raises ValueError to protect you. items: list = [] fails at class definition. Use items: list = field(default_factory=list) so each instance gets its own fresh list.
field() and __post_init__
Mutable defaults (lists, dicts) cannot be used directly — the same gotcha as mutable default arguments. The field() function with default_factory solves this. __post_init__ runs validation or derived-value computation after the generated __init__.
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
# WRONG: items: list = [] — shared mutable default, raises ValueError
# RIGHT: use default_factory
items: list = field(default_factory=list)
tags: dict = field(default_factory=dict)
c1 = ShoppingCart("Priya")
c1.items.append("book")
c2 = ShoppingCart("Arjun")
print(c2.items) # [] — independent, not shared
# field() options
@dataclass
class Account:
name: str
balance: float = 0.0
# exclude from __repr__ and __init__
_id: int = field(default=0, repr=False)
# computed, not passed to __init__
display: str = field(init=False, default="")
def __post_init__(self):
# runs after __init__ — validation and derived values
if self.balance < 0:
raise ValueError("Balance cannot be negative")
self.display = f"{self.name} (${self.balance:.2f})"
a = Account("Priya", 100.0)
print(a.display) # "Priya ($100.00)"items: list = [] raises ValueError in a dataclass (Python protects you here, unlike plain functions). Always use field(default_factory=list).✅ Intermediate tab complete
- I use field(default_factory=list) for mutable defaults — never items: list = []
- I can make an immutable, hashable dataclass with frozen=True
- I can generate comparison operators with order=True
- I can validate or compute derived values in __post_init__
Code generation and the introspection API
What you’ll learn: How @dataclass generates methods via exec() at class-creation time (zero runtime overhead), plus fields(), asdict(), astuple(), InitVar, and __dataclass_fields__.
How to read this tab: Read when building libraries or when you need to introspect dataclass structure programmatically.
How @dataclass generates code at class-creation time
The @dataclass decorator runs once, when the class is defined. It inspects __annotations__, builds the source code for each method as a string, compiles it with exec(), and attaches the resulting functions to the class. This is why dataclasses have zero runtime overhead compared to hand-written classes — the generated methods are ordinary Python methods, indistinguishable from ones you wrote yourself.
from dataclasses import dataclass, fields, asdict, astuple
@dataclass
class Point:
x: int
y: int
# Introspection API
p = Point(1, 2)
for f in fields(Point):
print(f.name, f.type, f.default) # x int <factory>, y int <factory>
# Conversion helpers
print(asdict(p)) # {'x': 1, 'y': 2} — recursive
print(astuple(p)) # (1, 2)
# The generated __init__ is a real function you can inspect
import inspect
print(inspect.getsource(Point.__init__))
# def __init__(self, x: int, y: int) -> None:
# self.x = x
# self.y = y
# __dataclass_fields__ holds the field metadata
print(Point.__dataclass_fields__.keys()) # dict_keys(['x', 'y'])The InitVar type marks a parameter that is passed to __init__ and forwarded to __post_init__ but not stored as a field. Combined with field(init=False) and __post_init__, this gives precise control over the initialisation pipeline — useful for fields whose values are derived from other fields rather than passed in directly.
✅ Expert tab complete
- I know @dataclass generates real methods at definition time with zero runtime overhead
- I can use fields() and asdict() to introspect and convert dataclasses
- I know InitVar marks init-only parameters forwarded to __post_init__