dataclasses (Deep API)
Beyond the @dataclass decorator itself, the dataclasses module offers a full API surface -- field(), fields(), asdict(), astuple(), replace(), and InitVar -- for fine-grained control and runtime introspection.
The Dataclasses topic covers what @dataclass generates and its main options (order, frozen, slots). This companion topic goes deeper into the dataclasses module itself: the full field() parameter set, the module-level introspection and conversion functions (fields, asdict, astuple, replace), init-only pseudo-fields via InitVar, and the mechanics behind frozen and slots.
Scope of This Topic
This page assumes you already know what @dataclass does and how to declare basic fields -- see the Dataclasses topic for that foundation. Here, the focus is the dataclasses module's programmatic API: what you can configure per field, what you can introspect at runtime, and what happens under the hood for frozen and slots.
from dataclasses import dataclass, field, fields, asdict, astuple, replace, InitVarfield() in Depth
field() customizes a single field beyond a plain default value. Its parameters: default (a fixed value), default_factory (a zero-argument callable invoked per instance -- required instead of default for mutable values), init (whether the field appears in the generated __init__; default True), repr (whether it appears in __repr__; default True), compare (whether it participates in __eq__/ordering; default True), hash (override whether it's included in __hash__; default None, meaning follow compare), metadata (an arbitrary read-only mapping attached to the field, ignored by dataclasses itself but readable by other code), and kw_only (Python 3.10+, forces the field to be keyword-only in __init__).
repr=False and compare=False don't remove the field -- it still exists and is still settable -- they only exclude it from the specific generated method.
from dataclasses import dataclass, field
@dataclass
class User:
username: str
password_hash: str = field(repr=False) # hidden from __repr__
login_count: int = field(default=0, compare=False) # excluded from __eq__
tags: list[str] = field(default_factory=list) # fresh list per instance
u = User('ana', 'abc123')
print(u) # User(username='ana', login_count=0, tags=[]) -- password_hash omittedfields(): Introspecting a Dataclass at Runtime
fields(class_or_instance) returns a tuple of Field objects describing every field: its name, type, default, default_factory, and the metadata described above. This lets generic code -- serializers, form generators, ORMs -- inspect a dataclass's shape without hardcoding field names.
dataclasses.MISSING is a sentinel meaning 'no default was set' -- it's how field() distinguishes 'default is genuinely None' from 'no default at all'.
@dataclass
class Point:
x: float
y: float = 0.0
for f in fields(Point):
print(f.name, f.type, f.default)
# x <class 'float'> <class 'dataclasses.MISSING'>
# y <class 'float'> 0.0asdict() and astuple(): Recursive Conversion
asdict(instance) recursively converts a dataclass instance (and any nested dataclasses, lists, tuples, and dicts it contains) into plain dicts, lists, and tuples -- handy for JSON serialization. astuple(instance) does the same but produces plain tuples. Both perform a deep copy of mutable field values, so mutating the result never affects the original instance.
asdict recurses into the nested Address dataclass automatically, which is exactly why it's a common choice for preparing dataclasses to pass to json.dumps().
Because asdict/astuple deep-copy, they can be expensive for large or deeply nested structures, and they will fail on fields holding uncopyable objects (like an open file or a lock). For a shallow, non-recursive view, use vars(instance) or dataclasses.fields() directly instead.
@dataclass
class Address:
city: str
zip_code: str
@dataclass
class Person:
name: str
address: Address
p = Person('Ada', Address('London', 'W1'))
print(asdict(p)) # {'name': 'Ada', 'address': {'city': 'London', 'zip_code': 'W1'}}
print(astuple(p)) # ('Ada', ('London', 'W1'))replace(): Creating a Modified Copy
replace(instance, **changes) returns a new instance of the same class with the given fields replaced and all others copied from the original, by calling __init__ again with the merged values. It's the standard way to 'change' an immutable (frozen) dataclass -- and works for mutable dataclasses too, when you want a modified copy rather than an in-place mutation.
Because replace() re-runs __init__, any validation or normalization logic in __post_init__ runs again on the new instance too -- it's not a raw attribute copy.
@dataclass(frozen=True)
class Point:
x: float
y: float
p1 = Point(1.0, 2.0)
p2 = replace(p1, y=5.0)
print(p1, p2) # Point(x=1.0, y=2.0) Point(x=1.0, y=5.0)
print(p1 is p2) # False -- a genuinely new objectInitVar: Init-Only Pseudo-Fields
InitVar[type] declares a pseudo-field that's accepted as a parameter by the generated __init__ but is never stored as an instance attribute and never appears in fields(), asdict(), or __repr__. It's passed through to __post_init__, which is the place to use it -- typically to compute a real field's value from a value that shouldn't be kept around.
__post_init__ receives InitVar parameters (in declaration order) after any of its own logic needs them -- here unit is used only to print a message and is then discarded, never stored on self.
from dataclasses import dataclass, field, InitVar
@dataclass
class Circle:
radius: float
diameter: float = field(init=False) # computed, not passed in directly
unit: InitVar[str] = 'cm' # accepted by __init__, never stored
def __post_init__(self, unit: str) -> None:
self.diameter = self.radius * 2
print(f'created a circle measured in {unit}')
c = Circle(3.0, unit='inches')
print(c.diameter) # 6.0
print(hasattr(c, 'unit')) # False -- InitVar never becomes an attributedefault_factory in Depth
default_factory must be a zero-argument callable -- list, dict, set, a user-defined function, or a lambda. Because it takes no arguments, computing a default that depends on another field can't be done directly in field(); instead, leave the dependent field with init=False and compute it in __post_init__, which does have access to self and every other field already set.
Note default_factory=time.time (no parentheses) -- you pass the callable itself, not its result; dataclasses calls it once per new instance during __init__.
from dataclasses import dataclass, field
import time
@dataclass
class Event:
name: str
created_at: float = field(default_factory=time.time) # zero-arg callable, no ()
tags: set[str] = field(default_factory=set)
e = Event('deploy')
print(e.tags) # set() -- a fresh set, independent per instancefrozen=True: How Immutability Is Enforced
frozen=True works by generating __setattr__ and __delattr__ methods that unconditionally raise dataclasses.FrozenInstanceError, overriding the normal attribute-assignment machinery. Even __init__ itself must use object.__setattr__() internally to set fields on a frozen instance the first time, since the normal self.x = value path is blocked. This is also why __post_init__ on a frozen dataclass must use object.__setattr__(self, 'field', value) directly if it needs to set a field after construction -- ordinary assignment would raise.
object.__setattr__ bypasses the dataclass's own overridden __setattr__ by calling the base implementation directly -- this is the sanctioned escape hatch for frozen dataclasses that still need one-time normalization in __post_init__.
from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
amount: float
currency: str
def __post_init__(self) -> None:
object.__setattr__(self, 'currency', self.currency.upper())
m = Money(9.99, 'usd')
print(m.currency) # 'USD' -- normalized despite frozen
try:
m.amount = 0
except Exception as e:
print(type(e).__name__) # FrozenInstanceErrorslots=True in Depth
@dataclass(slots=True) (Python 3.10+) generates a __slots__ tuple from the declared fields and rebuilds the class without a per-instance __dict__, reducing memory per instance and preventing arbitrary extra attributes. Two caveats: a slotted dataclass cannot also declare class-level default values the old (pre-dataclass) __slots__ way, since @dataclass itself manages defaults through field()/= syntax; and slots interacts with inheritance carefully -- a slotted subclass should generally also use slots=True, or it silently regains a __dict__ through the non-slotted parent, defeating the memory savings.
slots=True is a pure memory/attribute-restriction optimization; it doesn't change equality, ordering, or any other generated behavior of the dataclass.
@dataclass(slots=True)
class Pixel:
x: int
y: int
p = Pixel(0, 0)
print(Pixel.__slots__) # ('x', 'y')
try:
p.z = 5 # AttributeError -- no __dict__, no undeclared attributes
except AttributeError as e:
print('caught:', e)Common Mistakes
The deep-API mistakes tend to cluster around InitVar (forgetting it's not a real field), default_factory (passing a called value instead of the callable), and frozen (trying self.x = value instead of object.__setattr__ inside __post_init__).
Complete Example: Combining the Deep API
discount_pct is accepted by __init__ and consumed in __post_init__, but never becomes a stored field -- it's absent from fields(), asdict(), and the repr, exactly as InitVar promises.
from dataclasses import dataclass, field, fields, asdict, replace, InitVar
@dataclass(frozen=True)
class Product:
name: str
price: float
discount_pct: InitVar[float] = 0.0
final_price: float = field(init=False)
def __post_init__(self, discount_pct: float) -> None:
computed = self.price * (1 - discount_pct / 100)
object.__setattr__(self, 'final_price', round(computed, 2))
p1 = Product('Widget', 100.0, discount_pct=10)
print(p1.final_price) # 90.0
print([f.name for f in fields(p1)]) # ['name', 'price', 'final_price'] -- no discount_pct
print(asdict(p1)) # dict without discount_pct either
p2 = replace(p1, price=200.0, discount_pct=10) # re-runs __post_init__
print(p2.final_price) # 180.0Key Takeaways
field() controls per-field behavior: default_factory for mutable defaults, init/repr/compare/hash to opt a field out of generated methods, metadata for arbitrary attached info, and kw_only for keyword-only parameters.
fields() introspects a dataclass's shape at runtime; asdict()/astuple() recursively deep-copy an instance into plain dict/tuple structures.
replace() produces a modified copy by re-running __init__ (and therefore __post_init__), not by copying raw attributes.
InitVar declares a value accepted by __init__ and passed to __post_init__ that is never stored as a real field.
frozen=True works by overriding __setattr__/__delattr__ to raise; use object.__setattr__() inside __post_init__ when a frozen dataclass genuinely needs to set a field once.
slots=True (3.10+) removes the per-instance __dict__ for memory savings, but subclasses should also use slots=True to keep that benefit.
@dataclass class Circle: radius: float unit: InitVar[str] = 'cm' c = Circle(3.0) print(c.unit) # AttributeError -- InitVar is never stored
@dataclass class Circle: radius: float unit: InitVar[str] = 'cm' def __post_init__(self, unit): print(f'measured in {unit}') # use it inside __post_init__ instead
InitVar fields are consumed by __post_init__ and then discarded -- they never become instance attributes, so accessing them afterward raises AttributeError.
tags: list[str] = field(default_factory=list()) # calls list() immediately -- passes [] as the 'factory' tags: list[str] = field(default_factory=list) # pass the callable itself, not its result default_factory must be a zero-argument callable, called fresh per instance. Adding () calls it once at class-definition time and passes the resulting single empty list as the 'factory' (which then fails, since a list isn't callable), rather than deferring creation.
@dataclass(frozen=True) class Money: amount: float def __post_init__(self): self.amount = round(self.amount, 2) # FrozenInstanceError
@dataclass(frozen=True) class Money: amount: float def __post_init__(self): object.__setattr__(self, 'amount', round(self.amount, 2)) frozen=True overrides __setattr__ to always raise FrozenInstanceError, including inside __post_init__ -- object.__setattr__() bypasses that override to make the one legitimate post-construction assignment.
What does field(compare=False) change about a dataclass field, and what does it leave unchanged?
It excludes that field from the generated __eq__ (and ordering methods, if order=True) so two instances can be equal even with different values for it; the field is still stored, still settable, and still appears in __repr__ and __init__ unless separately excluded there too.
Why must default_factory be passed without parentheses, e.g. field(default_factory=list) rather than field(default_factory=list())?
default_factory expects the callable itself so it can invoke it fresh for every new instance; calling it immediately (list()) computes a single value once at class-definition time and hands that already-built object as the (non-callable) argument, defeating the whole purpose of per-instance freshness.
What is the difference between asdict(instance) and dataclasses.fields(instance) for inspecting a dataclass?
fields() returns metadata about the class's field definitions (Field objects with name, type, default, etc.), not values, and does not recurse; asdict() returns the instance's actual current values, recursively converted into a plain, deep-copied dict, including nested dataclasses.
Why does replace() re-run __post_init__ on the new instance instead of just copying attributes directly?
replace() builds the new instance by calling the class's __init__ again with the merged field values, so any validation, normalization, or computed-field logic in __post_init__ executes again exactly as it would for a freshly constructed instance -- it isn't a raw memberwise copy.
What happens to an InitVar-declared field after __post_init__ finishes, and where can you find its value afterward?
It is discarded entirely -- InitVar fields are never stored as instance attributes and never appear in fields(), asdict(), astuple(), or __repr__; if you need the value later, __post_init__ must explicitly store it into a real field (or use it to compute one).
Why does a frozen dataclass need object.__setattr__() inside __post_init__ instead of ordinary attribute assignment?
frozen=True generates a __setattr__ override that unconditionally raises FrozenInstanceError for any assignment, including ones from inside the class's own methods; object.__setattr__(self, name, value) calls the base object class's implementation directly, bypassing that override for the one legitimate post-construction write.
What memory/behavior tradeoff does slots=True introduce, and what is one caveat when using it with inheritance?
It removes the per-instance __dict__ (saving memory and blocking arbitrary extra attributes) by generating __slots__ from the declared fields; the caveat is that a subclass of a slotted dataclass should also declare slots=True, otherwise it silently regains a __dict__ through normal Python subclassing rules and the memory savings are lost.
Write a frozen dataclass Temperature with a celsius field and an InitVar 'as_fahrenheit' that, if given True, causes __post_init__ to print the equivalent Fahrenheit value (without storing it as a field).
from dataclasses import dataclass, InitVar @dataclass(frozen=True) class Temperature: celsius: float as_fahrenheit: InitVar[bool] = False def __post_init__(self, as_fahrenheit: bool) -> None: if as_fahrenheit: print(f'{self.celsius}C = {self.celsius * 9 / 5 + 32}F') t = Temperature(20.0, as_fahrenheit=True) print(hasattr(t, 'as_fahrenheit')) # False as_fahrenheit is an InitVar, so it's accepted by the generated __init__ and forwarded to __post_init__ but never stored as an attribute -- hasattr(t, 'as_fahrenheit') is False even though the value was used during construction to print the conversion.
What does this print, and why are c1 and c2's item lists independent?
['book'] [] -- default_factory=list calls list() separately for each Cart instance, giving c1 and c2 their own distinct list objects; asdict() then deep-copies c1's list into the returned dict, so mutating the returned dict's list afterward wouldn't affect c1 either.
Write a function that takes a frozen Point(x, y) dataclass instance and returns a new instance shifted by (dx, dy) using replace(), without mutating the original.
from dataclasses import dataclass, replace @dataclass(frozen=True) class Point: x: float y: float def shift(p: Point, dx: float, dy: float) -> Point: return replace(p, x=p.x + dx, y=p.y + dy) p1 = Point(1.0, 1.0) p2 = shift(p1, 2.0, -1.0) print(p1, p2) replace(p, x=..., y=...) constructs a brand-new Point via __init__ with the given fields overridden and all others copied from p1, leaving the frozen original p1 completely untouched -- result: Point(x=1.0, y=1.0) Point(x=3.0, y=0.0).
from dataclasses import dataclass, field, asdict
@dataclass
class Cart:
items: list = field(default_factory=list)
c1 = Cart()
c2 = Cart()
c1.items.append('book')
print(asdict(c1)['items'], c2.items)