dataclasses Module Complexity¶
The dataclasses decorator writes the methods a data-holding class needs — __init__, __repr__,
__eq__ and optionally the ordering four — as Python source, compiles them, and attaches them to
the class. All of that happens once, when the module is imported. What is left at run time is
ordinary attribute access.
Two sizes matter. n is the fields on one class. N is the values a recursive walk reaches,
which is what asdict() and astuple() actually pay for — a flat class with 400 fields and a
chain of 400 nested one-field objects both cost their node count, not fields times depth.
Complexity Reference¶
| Operation | Time | Space | Notes |
|---|---|---|---|
@dataclasses.dataclass |
O(n) | O(n) | n = fields; the generated methods are compiled once per class, at import |
dataclasses.field(...) |
O(1) | O(1) | Records the options; default_factory is called per instance, not here |
dataclasses.Field |
O(1) | O(1) | What field() returns and fields() hands back |
| Attribute access on an instance | O(1) | O(1) | An ordinary instance __dict__ lookup; no descriptor is involved |
Generated __init__(...) |
O(n) | O(n) | The space is the fields it stores; slots=True keeps that O(n) at a smaller per-field constant. Factory and __post_init__ costs are additional |
Generated __repr__() |
O(n) | O(n) | Formats every field into one string |
Generated __eq__(other) |
O(n) | O(n) | Compares the fields as two tuples; from 3.13 those are not materialized, making it O(1) |
Generated __lt__/__le__/__gt__/__ge__ |
O(n) | O(n) | With order=True, the same tuple comparison |
Generated __hash__() |
O(n) | O(n) | Only with frozen=True, eq=True; eq=True alone sets __hash__ to None |
dataclasses.replace(obj, **changes) |
O(n) | O(n) | Re-runs __init__, and therefore __post_init__; rejects an init=False field, with ValueError before 3.13 and TypeError from 3.13 |
dataclasses.asdict(obj) |
O(N) | O(N) | N = values reached; recurses into dataclasses, lists, tuples and dicts, and deep-copies what it finds |
dataclasses.astuple(obj) |
O(N) | O(N) | The same walk into a tuple |
dataclasses.fields(obj) |
O(n) | O(n) | Builds a fresh tuple each call; nothing is cached |
dataclasses.is_dataclass(obj) |
O(1) | O(1) | One attribute check, true for a class and for its instances |
dataclasses.make_dataclass(name, fields) |
O(n) | O(n) | Builds the class at run time, then applies the decorator |
dataclasses.InitVar[T] |
O(1) | O(1) | Marks a parameter that reaches __post_init__ and is not stored |
dataclasses.KW_ONLY |
O(1) | O(1) | A marker in the field order; every field after it is keyword-only |
dataclasses.MISSING |
O(1) | O(1) | The sentinel for "no default", distinct from None |
dataclasses.FrozenInstanceError |
O(1) | O(1) | An AttributeError, raised by a frozen instance's __setattr__ |
Basic Dataclass¶
Simple Definition¶
from dataclasses import dataclass
# Generating the methods is O(n) in fields, and happens once at import
@dataclass
class Point:
x: float
y: float
# Creating an instance is O(n): one assignment per field
point = Point(1.0, 2.0)
assert point.x == 1.0 # O(1) - an ordinary attribute lookup
assert repr(point) == 'Point(x=1.0, y=2.0)' # O(n)
assert point == Point(1.0, 2.0) # O(n)
Defaults and Factories¶
A plain default is stored on the class once. A default_factory is a callable invoked inside
__init__, so it costs whatever it costs — per instance, not per class.
from dataclasses import dataclass, field
@dataclass
class Config:
name: str
retries: int = 3 # evaluated once, at class creation
tags: list = field(default_factory=list) # called on every instance
first, second = Config('a'), Config('b')
assert first.retries == 3
assert first.tags == [] and first.tags is not second.tags
A mutable default is rejected, not shared
tags: list = [] raises ValueError when the class is created. That is the module refusing
the shared-mutable-default bug rather than letting you write it.
Equality, Ordering and Hashing¶
The generated __eq__ compares the fields as two tuples, so it is O(n) — and it is also what
decides whether the class can go in a set.
from dataclasses import dataclass
@dataclass
class Mutable:
a: int
@dataclass(frozen=True)
class Frozen:
a: int
@dataclass(eq=False)
class Identity:
a: int
# eq=True (the default) replaces __hash__ with None: the class becomes unhashable
assert Mutable.__hash__ is None
try:
{Mutable(1)}
except TypeError as error:
assert 'unhashable' in str(error)
# frozen=True with eq=True gets a generated __hash__ - O(n) over the fields
assert len({Frozen(1), Frozen(1)}) == 1
# eq=False keeps object identity, and with it the inherited __hash__
assert Identity(1) != Identity(1)
assert len({Identity(1), Identity(1)}) == 2
Ordering¶
order=True generates the four comparison methods, each an O(n) tuple comparison over the fields
in declaration order.
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
assert Version(1, 2) < Version(1, 10) # O(n)
assert sorted([Version(2, 0), Version(1, 5)])[0] == Version(1, 5)
Frozen Instances¶
frozen=True installs a __setattr__ that raises. It also changes __init__: each field is
assigned through object.__setattr__ rather than directly, which is why constructing a frozen
instance costs a little more than a mutable one.
from dataclasses import FrozenInstanceError, dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
point = Point(1.0, 2.0)
try:
point.x = 5.0
except FrozenInstanceError as error:
assert 'cannot assign' in str(error)
# It is an AttributeError, so ordinary attribute handling still catches it
assert issubclass(FrozenInstanceError, AttributeError)
Init-Only Fields and Post-Init¶
InitVar parameters reach __post_init__ and are never stored. replace() re-runs __init__,
so __post_init__ runs again — which is the behaviour to know, because a derived field is
recomputed rather than copied.
from dataclasses import InitVar, dataclass, field, replace
@dataclass
class Measurement:
value: int
scale: InitVar[int] = 2
scaled: int = field(init=False, default=0)
def __post_init__(self, scale):
self.scaled = self.value * scale
first = Measurement(3)
assert (first.value, first.scaled) == (3, 6)
# replace() rebuilds through __init__, so scaled is recomputed - O(n)
assert replace(first, value=4).scaled == 8
# and a field it cannot pass to __init__ is refused outright.
# The exception is ValueError before 3.13 and TypeError from 3.13.
try:
replace(first, scaled=99)
except (TypeError, ValueError) as error:
assert 'init=False' in str(error)
Keyword-Only Fields¶
KW_ONLY is a marker in the field order rather than a field of its own: everything declared after
it becomes keyword-only in the generated __init__.
from dataclasses import KW_ONLY, dataclass, fields
@dataclass
class Request:
url: str
_: KW_ONLY
timeout: int = 30
assert Request('http://x', timeout=5).timeout == 5
try:
Request('http://x', 5)
except TypeError as error:
assert 'positional' in str(error)
# The marker leaves no field behind
assert [f.name for f in fields(Request)] == ['url', 'timeout']
Converting to dict and tuple¶
asdict() and astuple() are the expensive operations here, and not because of the field count.
Both walk recursively and deep-copy what they find, so their cost is the number of values
reached — a nested structure costs its whole size, not the top class's field count.
import copy
from dataclasses import asdict, astuple, dataclass
@dataclass
class Inner:
values: list
@dataclass
class Outer:
inner: Inner
label: str
outer = Outer(Inner([1, 2, 3]), 'x')
converted = asdict(outer) # O(N) in values reached
assert converted == {'inner': {'values': [1, 2, 3]}, 'label': 'x'}
# The list is a copy, not the original
assert converted['inner']['values'] is not outer.inner.values
assert astuple(outer) == (([1, 2, 3],), 'x') # O(N), same walk
# Access the existing instance dictionary - O(1) time and space
attributes = vars(outer)
assert attributes is outer.__dict__
assert attributes == {'inner': outer.inner, 'label': 'x'}
# A separate shallow dictionary copy - O(n) time and space
shallow = attributes.copy()
assert shallow == attributes and shallow is not attributes
assert shallow['inner'] is outer.inner
asdict() is not a cheap projection
For ordinary non-slotted instances, vars(obj) and obj.__dict__ return the existing
instance dictionary in O(1) time and space. Use vars(obj).copy() for a separate shallow
dictionary in O(n) time and space, or asdict() for the recursive copy in O(N).
Introspection¶
from dataclasses import MISSING, Field, dataclass, field, fields, is_dataclass
@dataclass
class Point:
x: float
y: float = 0.0
# fields() builds a fresh tuple every call - O(n), nothing is cached
assert fields(Point) == fields(Point)
assert fields(Point) is not fields(Point)
first = fields(Point)[0]
assert isinstance(first, Field)
assert first.name == 'x'
assert first.default is MISSING # the sentinel, not None
assert fields(Point)[1].default == 0.0
# is_dataclass answers for the class and for an instance - O(1)
assert is_dataclass(Point) and is_dataclass(Point(1.0))
assert not is_dataclass(object())
Building a Dataclass at Run Time¶
make_dataclass() is the decorator plus a type() call, so it costs the same O(n) — but at run
time rather than import time, and every call builds a new class.
from dataclasses import dataclass, fields, make_dataclass
Point = make_dataclass('Point', [('x', float), ('y', float)]) # O(n)
assert [f.name for f in fields(Point)] == ['x', 'y']
assert Point(1.0, 2.0) == Point(1.0, 2.0)
# Two calls give two unrelated classes
assert make_dataclass('P', [('x', int)]) is not make_dataclass('P', [('x', int)])
Inheritance¶
Fields are collected across the MRO in reverse order, so a subclass costs the total across its bases, and a base's defaults constrain what the subclass may declare without one.
from dataclasses import dataclass, fields
@dataclass
class Base:
a: int
@dataclass
class Derived(Base):
b: int
assert [f.name for f in fields(Derived)] == ['a', 'b'] # O(total fields)
assert Derived(1, 2).a == 1
# A field with a default cannot be followed by one without
try:
@dataclass
class Broken(Base):
c: int = 0
d: int
except TypeError as error:
assert 'non-default argument' in str(error)
Version Notes¶
- Python 3.10+:
KW_ONLY,kw_only,match_argsandslotson the decorator - Python 3.13+: the generated
__eq__no longer materializes the two tuples it compares, so its space drops from O(n) to O(1);replace()raisesTypeErrorrather thanValueErrorfor aninit=Falsefield
Related Documentation¶
- typing - the annotations a dataclass reads its fields from
- collections -
namedtuple, the immutable alternative - copy - what
asdict()does to the containers it finds
Best Practices¶
✅ Do:
- Use
frozen=Truewhen you want the instance in a set or dict;eq=Truealone makes it unhashable - Use
vars(obj)for a top-level view andasdict()only when you want the deep copy - Use
field(default_factory=...)for anything mutable - Keep
__post_init__cheap:replace()runs it again
❌ Avoid:
- Reading
asdict()as O(fields); it is O(values reached), and it copies them - Calling
fields()in a loop — it rebuilds its tuple every time - Very wide dataclasses in hot import paths; every field is compiled into the generated methods
- Assuming a dataclass is hashable