The Problem with Plain Defaults

When I first started using Python dataclasses, I fell into the classic trap of using mutable objects as default values. The code looked clean at first:

@dataclass
class UserProfile:
    name: str
    tags: List[str] = []

At face value, each instance of UserProfile seems to have its own tags list. In reality, the default list is created once at class definition time and shared across every instance that omits an explicit tags argument. This leads to subtle bugs that are hard to track down, especially when the class is used throughout a large application.

Common symptom: Modifying tags on one object unexpectedly changes the same list on other objects, causing data corruption that looks like a concurrency issue when it is not.

I once spent an entire sprint debugging why a background job kept overwriting another job's tag list. The root cause was the shared default list. Once I realized the pattern, the fix was simple but required a different way of expressing defaults.

The Solution: default_factory

Dataclasses provide a dedicated mechanism for handling mutable defaults: the field helper with a default_factory argument. Instead of assigning the mutable object directly, you pass a zero‑argument callable that will be invoked for each new instance.

from dataclasses import dataclass, field
from typing import List

@dataclass
class UserProfile:
    name: str
    tags: List[str] = field(default_factory=list)

Now every time a UserProfile is instantiated without an explicit tags value, a fresh empty list is created. The factory is called per instance, so the isolation problem disappears.

Why does this matter beyond dataclasses? The same pattern appears when using regular __init__ methods, namedtuple factories, or even simple class attributes. Relying on mutable defaults in any of those contexts can produce the same hidden coupling. The default_factory idiom is a small, explicit contract that tells the reader "each instance gets its own copy".

When to Apply It

There are a few scenarios where this technique shines:

  • Configuration objects that contain optional lists or dicts of settings (e.g., allowed roles, excluded paths).
  • State containers used in tests or simulations where you want a clean slate for each run.
  • Serialization helpers** that need to accumulate items during processing but must not leak between serializations.

Even immutable types like tuple benefit from default_factory when you need a default empty collection because tuple cannot be used as a default value directly (it is not callable). You can write field(default_factory=tuple) for consistency.

Putting It All Together – A Real‑World Example

Imagine a permission‑management service where each role defines a set of allowed actions and a list of inherited roles. The code below shows a production‑ready pattern using dataclasses with default_factory for both fields.

from dataclasses import dataclass, field
from typing import List, Set

@dataclass
class Role:
    name: str
    allowed_actions: Set[str] = field(default_factory=set)
    inherits_from: List[str] = field(default_factory=list)

    def add_action(self, action: str) -> None:
        self.allowed_actions.add(action)

    def inherit(self, parent: str) -> None:
        if parent not in self.inherits_from:
            self.inherits_from.append(parent)

# Usage
admin = Role(name="admin")
admin.add_action("delete")
admin.inherit("superuser")

user = Role(name="user")
user.add_action("read")

print(admin)   # Role(name='admin', allowed_actions={'delete'}, inherits_from=['superuser'])
print(user)    # Role(name='user', allowed_actions={'read'}, inherits_from=[])

Notice how admin and user start with empty collections. Adding an action to admin does not affect user, and the inheritance list is independent as well. This isolation is crucial when roles are cached or reused across request contexts.

If we had used plain defaults like allowed_actions: Set[str] = set(), a single set instance would have been shared, causing the permission system to incorrectly grant or deny rights. The default_factory pattern makes the intent explicit and prevents that class of bug.

Final Thoughts

Mutable defaults are a silent source of bugs that often surface only after deployments. Dataclasses give us a clean way to avoid them through field(default_factory=...). By adopting this pattern early, you write code that is easier to reason about, simpler to test, and more robust in production.

Whenever you see a dataclass attribute that could hold a list, dict, or any mutable container, ask yourself: Do I want each instance to start with its own copy? If the answer is yes, reach for default_factory. Your future self will thank you for the clarity and the reduced debugging time.