Python Dataclasses: Eliminating Boilerplate Without Shared-State Bugs
Use @dataclass to generate __init__ and __repr__ automatically, but mutable defaults, frozen instances, and inheritance ordering introduce subtle bugs if you skip the mechanics.
21 Jan 2026, 11:03 UTC

The problem with hand-written data containers
When a class exists mainly to hold values—a configuration record, a data-transfer object, or an internal state snapshot—writing __init__, __repr__, and __eq__ by hand is repetitive and error-prone. The useful answer is to decorate the class with @dataclass so Python generates those methods from class-level annotations, but the decorator has specific semantics around defaults, mutability, and inheritance that cause subtle bugs when misunderstood.
How the mechanism works
The dataclasses module inspects the class's __annotations__ dictionary to discover fields. For each annotated name, it creates a class-level Field object and generates an __init__ that assigns parameters to instance attributes in annotation order. It also supplies __repr__ and __eq__ implementations. Because the generated constructor follows the order in which annotations appear, field ordering is not just a style choice—it directly affects the function signature.
Worked example: an immutable configuration object
Suppose you need a configuration object that should not change after creation and that holds a list of ports. A naive version looks correct but hides two problems: a shared mutable default and a false sense of deep immutability.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Config:
name: str
ports: list[int] = field(default_factory=list)
In this example, frozen=True makes instances immutable at the top level: attempting config.name = "new" raises FrozenInstanceError. The default_factory=list argument ensures that every Config created without an explicit ports argument receives its own empty list. Without it, writing ports: list[int] = [] would bind one list object to the class, and every instance would share the same object.
You can inspect what the decorator generated by importing the class and calling fields(). Run the following in your terminal from the project root, replacing my_module with the actual module name and Config with your class name:
python -c "from dataclasses import fields; from my_module import Config; print(fields(Config))"
Expected check: the output lists each field name, type, and default. Required permissions: none beyond normal file-system access. Risk: if the module executes side effects on import, those side effects run.
Limits and common mistakes
Mutable defaults create shared state
The most frequent mistake is using a mutable object directly as a default. Because Python evaluates default arguments once at function-definition time, a list or dictionary default is shared across all instances. The fix is field(default_factory=list) or field(default_factory=dict), which calls the factory for each new instance.
frozen=True is shallow
frozen=True prevents attribute reassignment on the instance, but it does not recursively freeze nested containers. If a frozen dataclass contains a list, that list can still be mutated. For true immutability, use tuples for sequence fields or perform a deep copy when constructing the object.
Inheritance ordering constraints
Dataclass inheritance follows annotation order. If a parent class defines a field with a default and a subclass adds a field without a default, Python raises a TypeError because a non-default argument would follow a default one in the generated __init__. In Python 3.10 and newer, you can resolve this with kw_only=True on the decorator or on individual fields, which forces those parameters into keyword-only position and removes the ordering constraint.
Generated __eq__ compares every field
By default, __eq__ compares all fields by value. For large objects or classes where identity matters more than equality, this can be expensive or semantically wrong. You can pass eq=False to the decorator to suppress generation and supply your own method.
No built-in validation
Dataclasses do not validate types or values. An annotation of int does not prevent assigning a string. If you need validation, add __post_init__ or use a separate validation layer.
slots=True changes instance layout
The slots=True parameter, added in Python 3.10, stores attributes in a fixed-size slot array instead of a per-instance dictionary. This reduces memory usage but prevents assigning undeclared attributes and can break code that relies on __dict__. Verify your interpreter supports it before adopting it.
Verifying behavior on your interpreter
Dataclass behavior depends on the Python version. Confirm the active version first by running this in your terminal:
python --version
Expected check: a version string such as Python 3.11.x. If you need slots=True or kw_only, the version must be 3.10 or newer. Risk: none; this is a read-only check.
Next, confirm that the decorator generates a constructor and representation. Run this in your terminal:
python -c "from dataclasses import dataclass; @dataclass class T: a: int; print(T(1))"
Expected check: output resembling T(a=1). If you see a TypeError, the interpreter is too old or the class body is malformed.
Finally, test the mutable-default fix directly. Create a temporary script named check_defaults.py with the following content and run it with python check_defaults.py:
from dataclasses import dataclass, field
@dataclass
class Bad:
items: list = []
@dataclass
class Good:
items: list = field(default_factory=list)
b1, b2 = Bad(), Bad()
b1.items.append(1)
print("Bad shared:", b1.items, b2.items)
g1, g2 = Good(), Good()
g1.items.append(1)
print("Good isolated:", g1.items, g2.items)
Expected check: the Bad pair prints the same non-empty list, while the Good pair prints one empty and one non-empty list. This confirms that default_factory prevents cross-instance leakage.
Dataclasses remove real boilerplate, but they shift the burden from writing methods to understanding field ordering, default evaluation, and version-specific parameters. Treat the generated constructor as part of your public API and verify the version-specific features before relying on them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.