Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Python

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.

July 2026 7 min read 1 views 0 hearts

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:

  1. Always use field(default_factory=...) for mutable types – every time, no exceptions
  2. Never reference other fields in default factories – put that logic in __post_init__
  3. Be explicit about None handling – decide if None means "unset" or "no value"
  4. Reorder fields by scanning for defaults – use a linter to catch ordering issues
  5. 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.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.