Dataclasses
The @dataclass decorator (standard library, 3.7+) auto-generates boilerplate like __init__, __repr__, and __eq__ from simple field declarations, turning classes that mostly hold data into a few readable lines.
Many classes exist mainly to hold a fixed set of named values -- a Point, a Config, a Record -- and writing __init__, __repr__, and __eq__ for each one by hand is repetitive and error-prone. The dataclasses module's @dataclass decorator (standard library, introduced in Python 3.7) inspects a class's type-annotated class variables and generates that boilerplate automatically.
Basic Syntax: @dataclass
Decorate a class with @dataclass and declare its fields as annotated class-level variables (name: type). The decorator generates __init__ (accepting each field as a parameter), __repr__ (a readable string showing field values), and __eq__ (comparing all fields) automatically.
Without @dataclass, an equivalent plain class would need __init__ to assign each field, a hand-written __repr__, and a hand-written __eq__ -- @dataclass produces all three from the field declarations alone.
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__ compares fields
print(p.x, p.y) # 1.0 2.0Generated Methods
By default @dataclass generates __init__, __repr__, and __eq__. Additional methods are generated only when requested via decorator parameters: ordering comparisons (order=True), immutability (frozen=True), and hashing (controlled by eq/frozen, or explicitly via unsafe_hash=True).
@dataclass generates: __init__, __repr__, __eq__
@dataclass(order=True) adds: __lt__, __le__, __gt__, __ge__
@dataclass(frozen=True) adds: __setattr__/__delattr__ that raise; makes instances hashable by default
@dataclass(eq=False) skips __eq__ (falls back to identity comparison from object)Fields and Default Values
A field can have a default value, making it optional in the generated __init__. As with any Python function, parameters with defaults must come after parameters without them, so fields with defaults must be declared after fields without defaults.
@dataclass
class User:
name: str
active: bool = True # optional -- has a default
u1 = User("Ada")
u2 = User("Grace", active=False)
print(u1, u2)Mutable Defaults and default_factory
Just like a plain function, a dataclass field cannot use a mutable object (a list, dict, or set literal) directly as its default -- @dataclass explicitly raises ValueError at class-definition time if you try, precisely to prevent the shared-mutable-default bug that plain functions are silently vulnerable to. Instead use dataclasses.field(default_factory=...), passing a zero-argument callable that produces a fresh object for every instance.
field(default_factory=list) calls list() fresh for each new Team instance, so t1.members and t2.members never share the same underlying list object.
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list[str] = field(default_factory=list) # fresh list per instance
t1 = Team("Alpha")
t2 = Team("Beta")
t1.members.append("Ada")
print(t1.members, t2.members) # ['Ada'] [] -- independent liststry:
@dataclass
class Bad:
members: list[str] = [] # ValueError, caught immediately
except ValueError as e:
print("caught:", e)field() for More Control
dataclasses.field() controls per-field behavior beyond a simple default: default_factory for mutable defaults, repr=False / compare=False to exclude a field from the generated __repr__/__eq__/ordering, and init=False for a field the generated __init__ shouldn't accept as a parameter.
from dataclasses import dataclass, field
@dataclass
class LogEntry:
message: str
level: str = "INFO"
_internal_id: int = field(default=0, repr=False, compare=False) # hidden from repr/eqFrozen Dataclasses (Immutability)
@dataclass(frozen=True) makes instances immutable after construction: assigning to any field raises FrozenInstanceError (a subclass of AttributeError). This is useful for value objects that should never change once created, and frozen instances are hashable by default (as long as eq is also True, the default), so they can be used as dict keys or set members.
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(40.7, -74.0)
try:
c.lat = 0.0
except Exception as e:
print("caught:", type(e).__name__) # FrozenInstanceError
locations = {c: "New York"} # works -- frozen dataclasses are hashableOrdering With order=True
@dataclass(order=True) generates __lt__, __le__, __gt__, and __ge__ that compare instances field-by-field in declaration order, as if comparing tuples of the fields. This makes instances directly sortable with sorted() or min()/max().
from dataclasses import dataclass
@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))
# [Version(major=1, minor=0, patch=5), Version(major=1, minor=2, patch=0), Version(major=2, minor=0, patch=0)]kw_only: Keyword-Only Fields
@dataclass(kw_only=True) (Python 3.10+) makes every field keyword-only in the generated __init__ -- callers must pass field=value rather than relying on positional order. This avoids ordering constraints between defaulted and non-defaulted fields and makes call sites self-documenting. field(kw_only=True) can also be applied per-field instead of to the whole class.
from dataclasses import dataclass
@dataclass(kw_only=True)
class Config:
debug: bool = False
retries: int = 3
host: str # order no longer constrained by defaults, since all args are keyword-only
c = Config(host="localhost", retries=5) # must use keyword arguments__post_init__: Post-Initialization Logic
__post_init__ is an ordinary method the generated __init__ calls automatically right after setting all the fields, useful for validation or computing a derived field that isn't itself a constructor parameter.
from dataclasses import dataclass, field
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False) # not a constructor parameter
def __post_init__(self) -> None:
if self.width <= 0 or self.height <= 0:
raise ValueError("dimensions must be positive")
self.area = self.width * self.height
r = Rectangle(3, 4)
print(r.area) # 12 -- computed automatically after constructionslots=True: Memory-Efficient Dataclasses
@dataclass(slots=True) (Python 3.10+) automatically generates a __slots__ definition for the class based on its fields, which removes the per-instance __dict__ and reduces memory usage -- particularly valuable when creating large numbers of instances. Slotted dataclasses cannot have arbitrary extra attributes assigned outside the declared fields.
from dataclasses import dataclass
@dataclass(slots=True)
class Pixel:
x: int
y: int
color: str
p = Pixel(0, 0, "red")
try:
p.extra = 1 # AttributeError -- no __dict__, no arbitrary attributes
except AttributeError as e:
print("caught:", e)Practical Usage and Comparison With Plain Classes
@dataclass is best for classes whose primary purpose is holding a fixed, named collection of values with straightforward equality/representation semantics -- configuration objects, simple records, DTOs (data transfer objects), coordinates. It's less suited to classes with substantial custom behavior or invariants that don't map cleanly onto plain field storage.
Feature Plain class @dataclass
__init__ hand-written generated from fields
__repr__ hand-written (or default object.__repr__) generated, readable
__eq__ identity by default field-by-field, generated
ordering (<, <=, ...) hand-written order=True
immutability hand-written frozen=True
mutable defaults same bug risk as functions blocked; use default_factoryEdge Cases
Inheriting one dataclass from another combines fields from base to subclass, but the same 'fields with defaults must come after fields without' rule applies across the combined field list -- a base class field with a default followed by a subclass field without one raises TypeError at class-definition time (unless kw_only is used, which lifts the restriction).
from dataclasses import dataclass
@dataclass
class Base:
a: int
b: int = 0 # has a default
try:
@dataclass
class Sub(Base):
c: int # no default, but comes after a defaulted field in the combined order
except TypeError as e:
print("caught:", e)Key Takeaways
@dataclass generates __init__, __repr__, and __eq__ from type-annotated class-level field declarations.
order=True adds comparison methods; frozen=True makes instances immutable and hashable; kw_only=True and slots=True (3.10+) add keyword-only fields and __slots__ respectively.
Mutable defaults (list, dict, set literals) are rejected outright -- use field(default_factory=...) for a fresh mutable object per instance.
__post_init__ runs automatically after the generated __init__, useful for validation or derived fields.
@dataclass is a standard library tool (Python 3.7+) best suited to classes whose main job is holding a fixed set of named values.
@dataclass class Team: members: list = [] # ValueError at class definition
@dataclass class Team: members: list = field(default_factory=list)
@dataclass explicitly rejects mutable literal defaults with ValueError; use field(default_factory=list) to get a fresh list per instance instead.
@dataclass class Point: x: int = 0 y: int # TypeError -- non-default field after a default field
@dataclass class Point: x: int y: int = 0 # non-default fields must come first
Just like ordinary function parameters, dataclass fields without defaults must be declared before fields with defaults, or class definition raises TypeError.
@dataclass class Rectangle: width: float height: float def __init__(self): # manually redefining __init__ defeats the point ...
@dataclass class Rectangle: width: float height: float # let @dataclass generate __init__ from the field declarations
Hand-writing __init__ on a dataclass overrides the generated one and defeats the purpose of using @dataclass; use __post_init__ for extra logic instead of replacing __init__ entirely.
What three methods does a plain @dataclass generate by default?
__init__ (accepting each declared field as a parameter), __repr__ (a readable representation showing field values), and __eq__ (comparing all fields for equality).
Why does @dataclass raise ValueError if you write members: list = [] as a field default, instead of silently using the plain function default-argument behavior?
Because a single [] object would be shared across every instance of the dataclass the same way a mutable default argument is shared across every call of a function; @dataclass proactively blocks this at class-definition time rather than letting the well-known mutable-default bug happen silently.
What does field(default_factory=list) do differently from field(default=[])?
default_factory takes a zero-argument callable (list) that is invoked fresh for every new instance, producing an independent list each time; a literal default=[] would (and is disallowed precisely because it would) share one list object across all instances.
What does @dataclass(frozen=True) change about a class's behavior?
It makes instances immutable after construction -- assigning to any field raises FrozenInstanceError -- and, combined with the default eq=True, makes instances hashable, so they can be used as dict keys or set elements.
When is __post_init__ called, and what is it typically used for?
It's called automatically by the generated __init__ immediately after all declared fields have been assigned; it's typically used for validating field values or computing a derived field that isn't a constructor parameter.
What problem does kw_only=True solve regarding field ordering?
It makes every field keyword-only in the generated __init__, removing the requirement that fields without defaults be declared before fields with defaults, since positional order no longer matters for keyword-only arguments.
What does slots=True do, and what is one behavioral consequence of using it?
It auto-generates __slots__ from the dataclass's fields, removing the per-instance __dict__ to reduce memory usage; a consequence is that instances can no longer have arbitrary extra attributes assigned beyond the declared fields (doing so raises AttributeError).
Write a frozen, ordered dataclass Money with fields amount (float) and currency (str), and demonstrate that two equal instances compare equal, can be sorted by amount, and are usable as a set element.
from dataclasses import dataclass @dataclass(frozen=True, order=True) class Money: amount: float currency: str m1 = Money(9.99, "USD") m2 = Money(9.99, "USD") m3 = Money(19.99, "USD") print(m1 == m2) # True print(sorted([m3, m1])) # [Money(amount=9.99,...), Money(amount=19.99,...)] print({m1, m2, m3}) # set with 2 elements -- m1 and m2 are equal and hash the same frozen=True makes Money immutable and hashable (given eq's default of True), order=True adds field-by-field comparison so sorted() works, and equal instances hash identically so a set correctly collapses m1 and m2 into one element.
What does this print, and why don't b1 and b2 share the same list?
['x'] [] -- default_factory=list calls list() separately for each new Box instance, so b1.items and b2.items are two distinct list objects, unlike what would happen with a shared mutable literal default.
Write a dataclass Circle with field radius, and a field-not-in-init derived attribute area computed in __post_init__, raising ValueError if radius is not positive.
from dataclasses import dataclass, field @dataclass class Circle: radius: float area: float = field(init=False) def __post_init__(self) -> None: if self.radius <= 0: raise ValueError("radius must be positive") self.area = 3.14159265 * self.radius ** 2 c = Circle(2.0) print(c.area) # 12.566... try: Circle(-1) except ValueError as e: print("caught:", e) area is excluded from the generated __init__'s parameters (field(init=False)) and is instead computed in __post_init__, which the generated __init__ calls automatically right after setting radius; passing a non-positive radius raises ValueError from within __post_init__.
from dataclasses import dataclass, field
@dataclass
class Box:
items: list = field(default_factory=list)
b1 = Box()
b2 = Box()
b1.items.append("x")
print(b1.items, b2.items)