Python Dataclasses — Mutable Default Traps That Break Prod
Shared list defaults corrupted customer orders across instances.
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- @dataclass auto-generates __init__, __repr__, __eq__ from field annotations
- frozen=True enables __hash__ and enforces immutability
- Use field(default_factory=...) for mutable defaults (lists, dicts)
- __post_init__ handles validation and computed fields
- Dataclasses are mutable by default; use tuple for frozen fields with mutable contents
- Performance: dataclasses are standard Python objects, not optimised like NamedTuple for reads
Imagine you're filling out a form at the doctor's office — name, age, blood type, allergies. Every patient has the same fields, just different values. A Python dataclass is like that pre-printed form: you define the fields once, and Python automatically handles all the repetitive admin work — printing your data, comparing two forms, and more. You just fill in the values.
Every Python developer has written a class that does nothing except hold some data — a User, a Product, a Config — and then spent ten minutes writing __init__, __repr__, and __eq__ methods that all look almost identical. It's the kind of work that feels productive but is really just noise. Python 3.7 introduced dataclasses precisely to kill this ceremony, and they've quietly become one of the most useful tools in a Python developer's daily toolkit.
The problem dataclasses solve is subtle but real: when you write a plain class to hold data, Python gives you almost nothing for free. You have to manually wire up the constructor, teach the class how to print itself sensibly, decide how two instances should be compared, and handle freezing if you want immutability. Doing all of that correctly — especially edge cases like mutable default arguments — is surprisingly easy to get wrong. Dataclasses generate all of that code for you, correctly, based on simple field declarations.
By the end of this article you'll understand exactly what a dataclass generates under the hood, when to reach for one versus a plain class or a NamedTuple, how to add validation and computed fields without fighting the framework, and the three mistakes that reliably catch developers off guard in production code. You'll also be ready to answer the dataclass questions that pop up in Python technical interviews.
What @dataclass Actually Generates — and Why That Matters
The @dataclass decorator is a code generator. It reads the class-level field annotations you write, then silently injects methods into your class at definition time. Understanding which methods it generates — and why each one exists — is the key to using dataclasses confidently instead of cargo-culting them.
By default, @dataclass generates four things: __init__ (so you can construct instances with keyword arguments), __repr__ (so printing an instance gives you something useful instead of a memory address), __eq__ (so two instances with identical field values compare as equal), and nothing else. That last point matters — it does NOT generate __hash__ by default, for a very deliberate reason we'll come back to.
The real payoff is not just saving lines. It's correctness. The generated __eq__, for example, compares all fields in the order they're declared, and it correctly returns NotImplemented when compared to an object of a different type — something a hand-rolled == often gets wrong. You're not just saving keystrokes; you're getting battle-tested behavior for free.
exec()s the method source code — you can see it yourself with import inspect; print(inspect.getsource(Product.__init__)) in Python 3.10+.inspect.getsource().Frozen Dataclasses, Post-Init Logic, and Computed Fields
Once you're comfortable with the basics, three features unlock genuinely sophisticated patterns: frozen=True for immutability, __post_init__ for validation, and field(init=False) for computed attributes that depend on other fields.
Setting frozen=True tells the decorator to generate __setattr__ and __delattr__ methods that raise FrozenInstanceError on any attempt to mutate the object after construction. It also enables __hash__ generation, which is why frozen dataclasses can safely be used as dictionary keys or added to sets. Mutable objects shouldn't be hashable — Python enforces this opinion deliberately.
__post_init__ is the escape hatch for logic that belongs at construction time but can't be expressed as a plain default. Validation, normalization, and computing fields that depend on other fields all live here. It runs automatically after the generated __init__ finishes, so all fields are guaranteed to be populated when your code runs. Combined with field(init=False, repr=True), you can attach derived attributes that are calculated once and never need to be passed by the caller — keeping your API clean while your object stays self-contained.
Dataclass vs Plain Class vs NamedTuple vs TypedDict — Full Comparison
Choosing the right data container is a decision that compounds. Python offers four main options: plain classes, NamedTuples, dataclasses, and TypedDicts. Each has a distinct design center.
| Feature | Plain Class | NamedTuple | Dataclass | TypedDict |
|---|---|---|---|---|
| Auto __init__ | No | Yes | Yes | N/A (dict) |
| Auto __repr__ | No | Yes | Yes | N/A |
| Auto __eq__ | No | Yes (tuple eq) | Yes (field-by-field) | N/A |
| Auto __hash__ | No | Yes | Only when frozen=True | N/A |
| Immutable option | Manual | Always | frozen=True | N/A |
| Mutable defaults | Manual | Not cleanly | field(default_factory=) | N/A |
| Post-init logic | In __init__ | No | __post_init__ | N/A |
| Typed dict keys | No | No | No | Yes (string keys) |
| Serialization | Manual | | asdict(), astuple() | dict itself |
| Performance (read) | Standard | Fastest | Standard (slots=True helps) | Dict access |
| Best for | Behaviour-heavy | Lightweight records | Most data-holding | JSON-like config |
TypedDict (from typing) is unique: it provides type hints for dictionary keys but does not generate any methods — instances are plain dicts. It's perfect for API response payloads where you want static analysis but don't need object behavior. Dataclasses remain the best all-rounder for structured data.
Using dataclasses.asdict() and dataclasses.astuple() for Serialization
One of the most practical features of dataclasses is built-in conversion to plain dicts and tuples. The functions dataclasses.asdict() and dataclasses.astuple() recursively convert a dataclass instance (and all nested dataclasses) into Python primitives, making JSON serialization trivial.
asdict() returns a dictionary where field names become keys. It handles nested dataclasses, lists of dataclasses, and other common collection types. astuple() similarly converts to a tuple in field order. Both functions create deep copies — they do not return the same objects, so modifying the result won't affect the original instance.
This is especially useful when you need to serialize your domain objects to JSON (via json.dumps) or pass them to a database driver that expects dicts. Because asdict is recursive, a single call can flatten an entire object graph.
astuple() perform deep copies. For large or deeply nested structures this can be expensive. If you need a shallow conversion, consider writing a custom method that copies only the top-level fields.asdict() in the view layer to convert domain dataclasses to JSON responses. When we introduced deeply nested order objects, response latency spiked due to deep copy overhead. The fix: a shallow helper that only converted top-level fields and lazy-loaded nested ones. Profile before committing to deep recursion.astuple() are the go‑to tools for converting dataclasses to plain Python types for serialization. They recurse into nested dataclasses but do a deep copy — be mindful of performance at scale.Keyword-Only Fields with KW_ONLY (Python 3.10+)
Python 3.10 introduced the KW_ONLY sentinel from the dataclasses module. When used as a field marker, it forces all fields declared after it to be keyword-only in the generated __init__. This solves a common pain point: preventing positional argument errors when a dataclass has many optional fields.
Without KW_ONLY, callers can accidentally pass a value for the wrong optional field by position. With KW_ONLY, every field after the sentinel must be named explicitly. This is especially useful for dataclasses with many fields where the order is not obvious, or where backward compatibility matters — you can later add new fields without breaking positional callers.
The sentinel itself is not a real field — it's just a marker for the code generator. It does not appear in __init__, __repr__, or equality comparisons. It works alongside frozen, slots, and other decorator options.
Dataclass vs Plain Class vs NamedTuple — Choosing the Right Tool
Knowing how to write a dataclass is only half the skill. The other half is knowing when NOT to use one. Python gives you three main options for data-holding objects, and they're not interchangeable.
A plain class is still the right choice when your object has significant behaviour — methods that do real work, internal state that shouldn't be exposed as fields, or a complex inheritance hierarchy. Reaching for @dataclass to add some free __repr__ to a class with ten methods is reasonable; using it as the base for a deep OOP hierarchy gets messy quickly.
NamedTuple (from the typing module) is the right choice when you need true immutability with tuple semantics — unpacking, indexing by position, and guaranteed hashability without any extra configuration. NamedTuples are also marginally faster for read-heavy access patterns because they're backed by actual tuples. Their weakness is that you can't easily add mutable defaults, computed fields, or post-init logic.
Dataclasses sit in the sweet spot: mutable by default (frozen when you want), rich feature set, extensible with regular methods, and compatible with tools like dataclasses.asdict() and dataclasses.astuple() for serialization. They're the default choice for config objects, API response models, domain entities, and anything you'd previously have written as a verbose plain class.
json.dumps(). This makes dataclasses a natural fit for API response models and configuration objects.Dataclass Inheritance — Parent and Child Field Interactions
Dataclasses support inheritance, but there's a critical constraint: if a parent dataclass has any field with a default value, every field in a child dataclass must also have a default. This is a direct consequence of how the generated __init__ constructs the signature — you can't have a non-default argument after a default argument.
Consider a base dataclass for a database entity: an id field with a default of None (auto-generated on save), and a created_at with a default of field(default_factory=datetime.now). Now a child dataclass adds a required name field. The generated __init__ would be __init__(self, id=None, created_at=..., name=...). That's invalid Python: name comes after defaults. The solution is to either give all child fields defaults, or restructure the hierarchy so defaults only appear in leaf classes. A common pattern is to use an abstract base class without defaults, then concrete implementations with all defaults.
Another gotcha: inherited field order matters. Python collects all fields from parent classes and combines them in reverse MRO order (most base first) for __init__ and __repr__. This can surprise you if you rely on positional arguments. Always use keyword arguments with dataclass constructors.
Slots Dataclasses and Performance Optimisation
Python 3.10 introduced the slots parameter @dataclass(slots=True). This tells the decorator to generate a class with __slots__ set, and to define slots for each field. Slots eliminate the per-instance __dict__, reducing memory usage by roughly 30-50% for large numbers of instances. Attribute access is also faster because slots bypass the dict lookup.
But slots come with trade-offs. You can't add arbitrary new attributes to a slots instance — no more obj.new_field = value without raising AttributeError. Inheritance becomes trickier: if a parent class uses slots, the child must also define slots to avoid conflicts. You also lose the ability to use weak references unless you explicitly include __weakref__ in __slots__.
For domain objects that you instantiate thousands of times — like event payloads, cache entries, or data transfer objects — slots=True is an easy win. For config objects or rarely created dataclasses, the benefit is negligible, and the flexibility loss may not be worth it.
Mutable Defaults: The Silent Data Corruption Bomb
You've seen it. A dataclass with a default empty list. Two instances, same list. One appends, the other sees it. This isn't a Python quirk — it's a reference trap baked into how Python function defaults work.
Dataclasses try to protect you. If you write items: list = [], the decorator catches it and raises a ValueError. It forces you to use field(default_factory=list). That's not bureaucracy — that's a guard rail.
default_factory calls a zero-argument callable every time a new instance is created. Each instance gets its own fresh mutable object. Lists, dicts, sets, custom objects — always use default_factory.
The trap deepens with nested structures. A dict of lists? Write a function or a lambda: field(default_factory=lambda: {'errors': []}). If you use the same list as a default across fields, you're sharing state across a class hierarchy.
Senior rule: If you see a mutable default in production code, flag it immediately. It's not style — it's correctness.
default_factory. If it's mutable, it's shared. No exceptions.Class Variables vs Instance Fields: The Annotation Ambush
Type annotations in a dataclass aren't just hints — they're field declarations. Every annotated variable becomes an instance field unless you explicitly mark it otherwise.
Want a class-level constant? Forget tricks. Use field()ClassVar from typing, or slap an underscore prefix. ClassVar tells the decorator: "hands off, this belongs to the class, not instances."
Without ClassVar, your "class variable" becomes an instance field, silently overriding what you intended. The __init__ method swallows it, and suddenly your shared config constant is per-instance.
Init-only variables (InitVar) are the opposite — they feed into __post_init__ but don't persist as fields. Use them for dependency injection or computed state that doesn't need to stick around.
The pattern: ClassVar for global config, InitVar for setup data, normal annotations for persistent state. Mix them and your codebase becomes a minefield of unexpected behavior.
InitVar for runtime configuration that's used only in __post_init__. It keeps your dataclass state clean and prevents accidental serialization of ephemeral data.Descriptor Fields: When Dataclasses Need Runtime Logic
Dataclasses generate __init__ and __setattr__ that bypass descriptor protocols. If you slap a @property or a custom descriptor on a field, the dataclass machinery will flat-out ignore it during construction.
This means validation, computed properties, or lazy loading inside a descriptor won't fire during __init__. You assign a raw value, the descriptor's __set__ never runs.
The fix: Use __post_init__ to trigger manual validation, or define the field with field(init=False) and handle assignment yourself. Better yet, for computed fields, use @property on the class directly — dataclasses won't interfere with properties defined outside the decorator.
Custom descriptors still work for attribute access after construction, but you must ensure the field is excluded from __init__. Otherwise, you get silent failures where validation never runs.
Production reality: Most descriptor patterns are overengineering for dataclasses. Keep it simple — if you need validation, do it in __post_init__. If you need computed state, use @property.
__set__ re-trigger in __post_init__ wastes a write. For performance-critical code, bypass dataclass and write the descriptor logic directly in __post_init__ with explicit validation.__init__. Validate in __post_init__ or use init=False and manual assignment.Python's Dataclass in a Nutshell
Dataclasses are a code generation tool. They automate the boilerplate of data containers: __init__, __repr__, __eq__, and __hash__. That's it. No magic, no metaprogramming overhead — just a decorator that writes methods you'd otherwise write by hand.
Why does this matter? Because every line of boilerplate you delete is a line that can't hold a hidden bug. When you write __init__ manually, you risk typo'd attribute names, wrong default values, or missed validations. Dataclasses eliminate that class of error entirely.
The real power isn't the decorator itself — it's the contract. A dataclass declares: "I am a data carrier with explicit fields, explicit types, and zero implicit behavior." That contract makes your code auditable and your refactors safe. Production teams swear by dataclasses because they turn a runtime mess into a compile-time constraint (well, as close as Python gets).
frozen=True unless you explicitly need mutation. It forces locality of change and prevents accidental state corruption across threads.Conclusion: What You Should Actually Do Next
Stop writing manual __init__ methods. Stop using dicts for structured data. Reach for @dataclass first, NamedTuple second (when ordering matters), and TypedDict only when interfacing with legacy dict-based APIs. That's the hierarchy.
Your takeaway from this guide should be sharpened judgment. Not "use dataclasses because they're new" — use them because they enforce discipline. Frozen dataclasses prevent mutation rot. KW_ONLY fields prevent argument-order spaghetti. __post_init__ catches bad data at construction, not three stack frames later.
For further reading: study the CPython source for dataclasses.py — it's 700 lines of pure Python, eminently readable. Then read Hynek Schlawack's blog posts on attrs (the progenitor). Finally, internalize PEP 557. The difference between a senior and a junior is knowing not just what the tool does, but why the tool exists and when to set it aside.
frozen=True with __hash__ logic without understanding the tuple-hash contract. Frozen dataclasses hash by all fields — if an element is mutable (like a list), you get a TypeError at runtime.frozen=True + kw_only=True. That's the senior baseline for any new dataclass.dataclasses.Field(): Precision Control Over Instance Fields
Standard dataclass fields are declared with type hints and optional defaults. But what if you need to enforce metadata, hide a field from __repr__, exclude it from comparison, or mutate it safely? That's where dataclasses. steps in. It's not a function you call directly in field definitions; instead, Python provides it behind the scenes when you use Field(). The field() factory returns a field()Field descriptor object that controls behavior at the class level. Key parameters include default, default_factory, init, repr, compare, hash, and metadata. For example, a compare=False field won't participate in equality checks, perfect for timestamps or internal IDs. The metadata dict lets you attach arbitrary data (e.g., validation rules) without polluting the instance namespace. Use on a dataclass to inspect its fields()Field objects programmatically. This is how you build production-grade, self-documenting schemas without sacrificing Python's dynamic nature.
default_factory for mutable defaults (list, dict) even with field(). The default parameter evaluates once at class creation, not per instance.field() parameters to fine-tune initialization, representation, comparison, and metadata—never rely on type hints alone for field behavior.Post-Init Processing: Hook Into the Birth of Every Instance
Dataclasses generate __init__ automatically, but real-world objects often need validation, normalization, or derived attributes right after creation. Enter __post_init__: a special method Python calls immediately after the generated __init__ finishes. It receives no arguments beyond self, but you can access freshly assigned fields. Common uses: converting a birthdate string to a dataclass, computing age from a birth year, enforcing business rules (e.g., end date after start date), or populating fields marked with init=False. Combine with field(init=False) to define computed attributes that don't clutter constructor signatures. For type safety, declare those fields with a type hint and assign inside __post_init__. This keeps your API clean while ensuring every instance is invariants-valid. Beware: __post_init__ runs during __init__, not deserialization or unpickling—re-hook accordingly. It's your last chance to mutate before the object is 'live'.
init=False fields outside __post_init__ without validation—they bypass constructor checks. Also, __post_init__ is not called by __init__ if the class overrides __init__ manually.__post_init__ for validation, derived values, and init-only fields—it's your constructor-level invariant enforcer before the object enters the wild.Dataclasses in Python 3.12: slots=True and weakref_slot
Python 3.12 introduced two powerful enhancements to dataclasses: the slots=True parameter and the weakref_slot parameter. These features allow for more memory-efficient and flexible dataclass definitions.
slots=True
When you define a dataclass with @dataclass(slots=True), Python generates a __slots__ attribute for the class. This prevents the creation of a __dict__ per instance, significantly reducing memory usage. Slots also speed up attribute access. However, note that slots dataclasses cannot have class variables that are also instance fields, and inheritance requires careful handling (all parent classes must also use slots).
Example: ```python from dataclasses import dataclass
@dataclass(slots=True) class Point: x: float y: float
p = Point(1.0, 2.0) print(p.x) # 1.0 # p.z = 3.0 # AttributeError: 'Point' object has no attribute 'z' ```
weakref_slot
When weakref_slot=True is set (requires slots=True), a __weakref__ slot is added to the class, enabling weak references to instances. By default, slots dataclasses do not support weak references. This is useful for caching or observer patterns.
Example: ```python import weakref from dataclasses import dataclass
@dataclass(slots=True, weakref_slot=True) class Node: value: int
n = Node(42) ref = weakref.ref(n) print(ref() is n) # True ```
Production Considerations
Using slots=True is a best practice for high-performance applications with many instances. However, be aware of limitations: you cannot dynamically add new attributes, and inheritance from non-slots classes is problematic. The weakref_slot option should be used only when weak references are needed, as it adds a small overhead.
slots=True for data-heavy objects like configuration entries or log records, but test inheritance chains carefully.slots=True and weakref_slot enable memory-efficient dataclasses with optional weak reference support.Frozen Dataclasses: Immutability for Data Objects
Frozen dataclasses provide immutability by setting frozen=True in the @dataclass decorator. Once an instance is created, its attributes cannot be modified. This is ideal for representing data that should not change, such as configuration constants, database records, or value objects.
How Frozen Dataclasses Work
When frozen=True, the generated __setattr__ and __delattr__ methods raise FrozenInstanceError if you try to modify an attribute. The class also becomes hashable if eq=True (default) and all fields are hashable, allowing instances to be used in sets and as dictionary keys.
Example: ```python from dataclasses import dataclass
@dataclass(frozen=True) class ImmutablePoint: x: float y: float
p = ImmutablePoint(1.0, 2.0) # p.x = 3.0 # FrozenInstanceError: cannot assign to field 'x' ```
Post-Init and Computed Fields
Even with frozen dataclasses, you can use __post_init__ to compute fields. However, you must use object.__setattr__ to set fields inside __post_init__ because direct assignment is forbidden.
Example: ```python from dataclasses import dataclass, field
@dataclass(frozen=True) class Rectangle: width: float height: float area: float = field(init=False)
def __post_init__(self): object.__setattr__(self, 'area', self.width * self.height)
r = Rectangle(3, 4) print(r.area) # 12.0 ```
Performance and Use Cases
Frozen dataclasses are slightly slower due to the attribute access checks, but the safety benefits often outweigh the cost. They are perfect for functional programming patterns, caching, and multi-threaded environments where immutability prevents race conditions.
object.__setattr__ in __post_init__ for computed fields.Dataclasses vs NamedTuple vs TypedDict: Comparison
Python offers several ways to define structured data: dataclasses, NamedTuple, and TypedDict. Each has its strengths and trade-offs. Understanding when to use which can improve code clarity and performance.
Dataclasses - Mutable by default, but can be frozen. - Full class features: methods, inheritance, __post_init__, slots. - Type annotations required. - Best for complex data objects with behavior.
NamedTuple - Immutable (like tuples). - Lightweight, memory-efficient. - Supports indexing and unpacking. - Cannot have methods easily (though you can subclass). - Best for simple, immutable data containers.
TypedDict - Not a class; it's a type hint for dictionaries. - Mutable, but no runtime enforcement. - Allows dynamic keys and values. - Best for JSON-like data or when you need dictionary flexibility.
Comparison Example ```python from dataclasses import dataclass from typing import NamedTuple, TypedDict
@dataclass class PointDC: x: float y: float
class PointNT(NamedTuple): x: float y: float
class PointTD(TypedDict): x: float y: float
# Usage p_dc = PointDC(1.0, 2.0) p_nt = PointNT(1.0, 2.0) p_td: PointTD = {'x': 1.0, 'y': 2.0} ```
When to Choose - Use dataclasses for most cases: they offer the best balance of features and readability. - Use NamedTuple for lightweight, immutable data that benefits from tuple behavior (e.g., unpacking). - Use TypedDict when interfacing with JSON APIs or when you need dictionary methods.
Performance NamedTuple is slightly faster for creation and attribute access than dataclasses. TypedDict is just a dict, so it's fast but lacks type safety at runtime.
Shared Mutable Default Corrupts Customer Orders
- Never use mutable defaults in dataclasses — let the decorator enforce it.
- If you override __init__, you're responsible for the correct default behavior.
- Testing with multiple instances would have caught the sharing: assert order1.tags is not order2.tags.
python3 -c "from dataclasses import fields; print(fields(MyClass))"grep -rn 'default_factory' src/| File | Command / Code | Purpose |
|---|---|---|
| product_dataclass.py | from dataclasses import dataclass, field | What @dataclass Actually Generates |
| order_dataclass.py | from dataclasses import dataclass, field | Frozen Dataclasses, Post-Init Logic, and Computed Fields |
| four_way_comparison.py | from dataclasses import dataclass | Dataclass vs Plain Class vs NamedTuple vs TypedDict |
| serialization_example.py | from dataclasses import dataclass, asdict, astuple | Using dataclasses.asdict() and dataclasses.astuple() for Ser |
| kw_only_example.py | from dataclasses import dataclass, field, KW_ONLY | Keyword-Only Fields with KW_ONLY (Python 3.10+) |
| tool_comparison.py | from dataclasses import dataclass, asdict, astuple | Dataclass vs Plain Class vs NamedTuple |
| inheritance_example.py | from dataclasses import dataclass, field | Dataclass Inheritance |
| slots_dataclass.py | from dataclasses import dataclass | Slots Dataclasses and Performance Optimisation |
| MutableDefaults.py | from dataclasses import dataclass, field | Mutable Defaults |
| ClassVarInitVar.py | from dataclasses import dataclass, InitVar | Class Variables vs Instance Fields |
| DescriptorFields.py | from dataclasses import dataclass, field | Descriptor Fields |
| dataclass_minimal.py | from dataclasses import dataclass | Python's Dataclass in a Nutshell |
| production_habit.py | from dataclasses import dataclass, field | Conclusion |
| field_control.py | from dataclasses import dataclass, field, fields | dataclasses.Field() |
| post_init_example.py | from dataclasses import dataclass, field | Post-Init Processing |
| slots_weakref.py | from dataclasses import dataclass | Dataclasses in Python 3.12 |
| frozen_dataclass.py | from dataclasses import dataclass, field | Frozen Dataclasses |
| comparison.py | from dataclasses import dataclass | Dataclasses vs NamedTuple vs TypedDict |
Key takeaways
Interview Questions on This Topic
What is the difference between using @dataclass(frozen=True) and manually setting attributes as read-only with properties? When would you choose one over the other?
Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
That's OOP in Python. Mark it forged?
13 min read · try the examples if you haven't