5 Hidden Dataclass Default Traps in Python
Mutable defaults, ordering constraints, frozen mutation surprises — discover the subtle gotchas lurking in Python dataclass field defaults, with practical workarounds from real debugging sessions.
The Hidden Traps in Python Dataclass Field Defaults
You sit down to write a clean dataclass, something like this:
from dataclasses import dataclass
from typing import List
@dataclass
class ShoppingCart:
items: List[str] = []
owner: str = "Guest"
Looks innocent, right? Run it, and boom—ValueError: mutable default <class 'list'> is not allowed: use default_factory. Python catches you before you even start. But that's just the warm-up. The real gotchas hide deeper.
The mutable default trap (the easy one)
Python dataclasses won't let you use mutable objects like lists, dicts, or sets as defaults directly. The reason? That same list object gets shared across all instances. Change it in one cart, and it changes everywhere.
The fix is default_factory:
from dataclasses import dataclass, field
from typing import List
@dataclass
class ShoppingCart:
items: List[str] = field(default_factory=list)
owner: str = "Guest"
PythonSkillset readers know this one. But here's where it gets interesting.
The default_factory timing gotcha
What if you want a default based on another field? Consider:
@dataclass
class Task:
priority: int = 1
history: List[str] = field(default_factory=lambda: [f"Created with priority {priority}"])
This crashes. The lambda captures priority from the class definition scope—before any instance exists. You get a NameError because priority isn't defined yet.
The workaround? Use __post_init__:
@dataclass
class Task:
priority: int = 1
history: List[str] = field(default_factory=list)
def __post_init__(self):
if not self.history:
self.history = [f"Created with priority {self.priority}"]
The None vs. mutable default confusion
Sometimes you want None to mean "no value yet", but still need a mutable default later. This trips up many Python developers:
@dataclass
class Config:
options: dict = None
def __post_init__(self):
if self.options is None:
self.options = {"theme": "dark"}
Works fine. But watch what happens when someone passes an empty dict:
config = Config(options={})
Now options is an empty dict, not None. Your __post_init__ skips it. Is that what you want? Probably not. Explicitly handle it:
def __post_init__(self):
if not self.options:
self.options = {"theme": "dark"}
The ordering constraint that surprises everyone
Dataclass fields with defaults must come after fields without defaults. That's Python syntax, but it creates subtle issues:
@dataclass
class User:
name: str
email: str
role: str = "viewer"
groups: List[str] = field(default_factory=list)
last_login: str = None # Might be set later
Works fine. But say you want to add id:
@dataclass
class User:
name: str
email: str
role: str = "viewer"
id: int # Error! Field without default after field with default
You must reorder. Python's __init__ expects positional arguments first, then keyword-only. This feels rigid, but it's the language.
The frozen dataclass mutation surprise
Add frozen=True to your dataclass, and suddenly you can't modify any fields—including mutable ones:
@dataclass(frozen=True)
class ImmutableCart:
items: List[str] = field(default_factory=list)
cart = ImmutableCart()
cart.items.append("milk") # Works! The list object isn't frozen
Wait, what? The reference to items is frozen, but the list itself isn't. So you can mutate the list content. This leads to bugs where someone thinks frozen means fully immutable.
Want true immutability? Use tuples or types.MappingProxyType:
@dataclass(frozen=True)
class SafeCart:
items: tuple = ()
The default comparison gotcha
Dataclasses generate __eq__ for you. But if you have mutable defaults that change:
@dataclass
class Cart:
items: list = field(default_factory=list)
a = Cart()
a.items.append("apple")
b = Cart()
b.items.append("apple")
print(a == b) # True - both have same items
Seems fine until you realize two separate carts with identical items are considered equal. For some use cases, that's wrong. Add a unique id field to distinguish:
from dataclasses import dataclass, field
from uuid import uuid4
@dataclass
class Cart:
id: str = field(default_factory=lambda: uuid4().hex)
items: list = field(default_factory=list)
What I've learned the hard way
After debugging enough dataclass oddities at PythonSkillset, here's my practical advice:
- Always use
field(default_factory=...)for mutable types – every time, no exceptions - Never reference other fields in default factories – put that logic in
__post_init__ - Be explicit about None handling – decide if None means "unset" or "no value"
- Reorder fields by scanning for defaults – use a linter to catch ordering issues
- Freeze carefully – remember it's shallow immutability
The truth is, dataclass defaults seem simple but hide complexity that bites you at runtime. Once you know these gotchas, they're easy to avoid. But the first time you hit them, they feel like the dataclass is out to get you.
It's not. It's just Python being Python—powerful, but insisting you think carefully about state.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.