Frozen dataclass mutable default: detection and resolution
Unexpected shared state in a frozen dataclass usually appears in production services that model configuration or API payloads, where a field is given a list or dict as a default. The default object is created once at class definition time, so every instance references the same mutable container. This silently corrupts downstream logic.
# Example showing the issue
import dataclasses
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
flags: list = [] # mutable default
s1 = Settings()
s2 = Settings()
# Mutate through one instance (allowed because the list itself is mutable)
s1.flags.append('debug')
print(f"s1.flags: {s1.flags}")
print(f"s2.flags: {s2.flags}")
print(f"len(s1.flags): {len(s1.flags)}, len(s2.flags): {len(s2.flags)}")
# Output shows both instances share the same list
The default value for a dataclass field is evaluated once when the class is defined. If that default is a mutable object (list, dict, set), every instance receives a reference to the same object. Frozen=True prevents rebinding the attribute but does not freeze the object’s contents. This follows the Python dataclasses documentation and is identical to the behavior of default arguments in functions. Related factors:
- Default evaluated at import time
- No default_factory used
- Mutability of the object itself
To diagnose this in your code:
# Detect mutable defaults in frozen dataclasses
from dataclasses import fields
import collections.abc
def warn_mutable_defaults(cls):
for f in fields(cls):
if isinstance(f.default, (list, dict, set)):
print(f"Warning: field '{f.name}' has mutable default {type(f.default).__name__}")
elif f.default_factory is not dataclasses.MISSING:
# default_factory is safe, skip
continue
warn_mutable_defaults(Settings)
Fixing the Issue
The quickest fix is to replace the literal mutable default with a default_factory:
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Settings:
flags: list = field(default_factory=list)
This creates a new list for each instance. For production code you may want to enforce the rule and catch accidental mutable defaults early:
import dataclasses, logging
def enforce_immutable_defaults(cls):
for f in dataclasses.fields(cls):
if isinstance(f.default, (list, dict, set)):
raise TypeError(
f"Field '{f.name}' in {cls.__name__} uses a mutable default; use default_factory instead"
)
return cls
@enforce_immutable_defaults
@dataclasses.dataclass(frozen=True)
class Settings:
flags: list = dataclasses.field(default_factory=list)
The decorator validates at import time, preventing the bug from reaching production. Logging can be added instead of raising if you prefer warnings.
What Doesn’t Work
❌ Setting the field to a tuple containing a list: flags: tuple = ([],) – the inner list is still mutable and shared
❌ Calling copy.deepcopy() inside post_init: self.flags = copy.deepcopy(self.flags) – defeats the purpose of frozen and adds runtime cost
❌ Switching frozen=False to avoid the issue – removes immutability guarantees and hides the real problem
- Using [] or {} directly as a default in a frozen dataclass
- Assuming frozen=True makes the entire object immutable
- Relying on deepcopy in post_init instead of default_factory
When NOT to optimize
- One‑off scripts: Small, throw‑away utilities where the shared list is inconsequential
- Read‑only data: If the mutable default never gets mutated after creation
- Performance‑critical tight loops: Overhead of default_factory is negligible, but if you already measured and accept the risk
- Legacy code under test: When rewriting is not feasible and tests already cover the shared‑state behavior
Frequently Asked Questions
Q: Can I mutate a frozen dataclass field that holds a list?
Yes, the reference is immutable but the list’s contents can still be changed.
Q: Is default_factory only for frozen dataclasses?
No, it is the recommended way to provide mutable defaults for any dataclass.
Mutable defaults are a classic Python gotcha that becomes especially sneaky with frozen dataclasses. By swapping literal defaults for default_factory and adding a simple validation step, you safeguard your configuration objects and keep your production pipelines reliable.