Python Frozen Dataclasses and Pseudo-Immutability

Python's dataclasses module allows developers to create structures that behave like immutable records by passing frozen=True to the @dataclass decorator. This article explains how frozen dataclasses enforce read-only behavior, why this protection is classified as pseudo-immutability rather than absolute immutability, and the common ways this boundary can be bypassed or weakened.

How Frozen Dataclasses Work

When you define a dataclass with @dataclass(frozen=True), Python automatically generates special implementations for attribute assignment and deletion:

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int
    y: int

Under the hood, the decorator injects __setattr__() and __delattr__() methods into the generated class. Whenever code attempts to modify an attribute (e.g., point.x = 10) or delete one (e.g., del point.x), these generated methods raise a dataclasses.FrozenInstanceError. Additionally, frozen dataclasses automatically generate a __hash__() method by default, allowing instances to be used as dictionary keys or set elements, provided all defined fields are also hashable.

Why It Is "Pseudo-Immutability"

Python is fundamentally a dynamic language that relies on convention ("we are all consenting adults here") rather than hard runtime memory protections. Because of this architecture, frozen dataclasses provide pseudo-immutability rather than true, hardware- or runtime-enforced immutability for two primary reasons:

1. Mutable Nested Objects

The "frozen" constraint only prevents reassignment of the top-level references held by the dataclass; it does not freeze the underlying objects themselves. If a field references a mutable object, that object can still be modified in place.

from dataclasses import dataclass
from typing import List

@dataclass(frozen=True)
class Team:
    members: List[str]

team = Team(members=["Alice", "Bob"])
# Reassignment fails:
# team.members = ["Charlie"]  -> Raises FrozenInstanceError

# Mutation succeeds:
team.members.append("Charlie")  # Modifies the internal list

In this scenario, team has changed state despite being marked as frozen. To achieve deeper immutability, mutable collections like list or dict must be replaced with immutable alternatives such as tuple or frozenset.

2. Direct Bypass via object.__setattr__

Because the freezing mechanism relies entirely on Python-level method interception, it can be bypassed directly using the base object methods.

point = Point(1, 2)
object.__setattr__(point, 'x', 10)
print(point.x)  # Outputs: 10

By calling object.__setattr__, the dataclass's custom __setattr__ check is completely circumvented. In fact, Python's own dataclass implementation relies on this exact mechanism during __init__ to populate the initial values without raising a FrozenInstanceError.

Summary

Frozen dataclasses provide defensive programming safeguards to prevent accidental reassignments in standard application workflows. However, because they do not protect nested mutable structures and can be bypassed via dynamic introspection, their immutability remains superficial—a pragmatic convention rather than an unbreakable guarantee.