33.4 Frozen Data Classes and Immutability
The frozen Parameter and Immutable Instances
By setting frozen=True in the @dataclass decorator, you instruct the dataclass to make its instances immutable. This means that after an instance is created, its fields cannot be assigned new values. Attempting to do so will raise a dataclasses.FrozenInstanceError. This immutability is enforced by the automatically generated __setattr__ method, which is overridden to prevent any attribute assignments after the initial object construction.
from dataclasses import dataclass
@dataclass(frozen=True)
class ImmutablePoint:
x: int
y: int
# Creating an instance works as usual
point = ImmutablePoint(10, 20)
print(point) # Output: ImmutablePoint(x=10, y=20)
# Attempting to modify a field raises a FrozenInstanceError
try:
point.x = 15
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
# Output: Error: FrozenInstanceError: cannot assign to field 'x'
This behavior is crucial for creating objects that are safe to use as keys in dictionaries or elements in sets, as their hash value will remain constant throughout their lifetime. It also provides a strong guarantee that the object’s state will not be accidentally altered by other parts of the code, leading to more predictable and debuggable programs.
How Immutability Interacts with Mutable Field Defaults
A significant pitfall arises when combining immutability with mutable default values. While the dataclass instance itself may be frozen, if one of its fields holds a reference to a mutable object (like a list or dict), the contents of that object can still be modified. The immutability of the dataclass only prevents you from reassigning the field to a new list; it does not prevent you from appending to or altering the existing list.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ImmutableConfig:
name: str
# DANGER: A mutable default for a frozen dataclass
permissions: list = field(default_factory=list)
config = ImmutableConfig("admin")
print(config) # Output: ImmutableConfig(name='admin', permissions=[])
# This is allowed and is a major pitfall:
config.permissions.append('read')
print(config) # Output: ImmutableConfig(name='admin', permissions=['read'])
# This is correctly blocked by the frozen dataclass:
try:
config.permissions = ['write']
except Exception as e:
print(f"Error: {type(e).__name__}") # Output: Error: FrozenInstanceError
The correct practice is to avoid mutable defaults in frozen dataclasses whenever possible. If a mutable field is necessary, it should be explicitly documented that while the field reference is immutable, the object it points to is not. For true deep immutability, you would need to use immutable types (like a tuple instead of a list) or implement a custom __post_init__ method to create a frozen copy of the mutable data.
Hashability and Use as Dictionary Keys
A key advantage of frozen dataclasses is that they are automatically made hashable. The @dataclass decorator will generate a __hash__ method if frozen=True is set (or if eq=True and frozen is not explicitly False). The generated hash value is based on the hash of all the field values in the instance. This allows the instances to be used reliably in hash-based collections like set and dict.
from dataclasses import dataclass
@dataclass(frozen=True)
class Employee:
employee_id: int
name: str
# Create instances
e1 = Employee(101, "Alice")
e2 = Employee(102, "Bob")
e3 = Employee(101, "Alice") # Duplicate of e1
# Instances are hashable
print(hash(e1)) # Output: An integer hash value
# They can be used in a set (duplicates are prevented)
unique_employees = {e1, e2, e3}
print(unique_employees)
# Output: {Employee(employee_id=101, name='Alice'), Employee(employee_id=102, name='Bob')}
# They can be used as dictionary keys
employee_directory = {e1: "Engineering", e2: "Marketing"}
print(employee_directory[e1]) # Output: Engineering
# The duplicate e3 will also match the key e1
print(employee_directory[e3]) # Output: Engineering
It is critical to remember that if a frozen dataclass contains fields that are themselves mutable (like our earlier ImmutableConfig example), the hash of the dataclass instance would change if the mutable field’s contents changed. This would break the invariants of the hash-based collection, leading to undefined behavior. Therefore, for a dataclass to be safely hashable, all of its fields must also contain hashable (and ideally immutable) values.
Inheritance with Frozen Data Classes
Inheritance works with frozen dataclasses, but it requires careful consideration. The frozen parameter is inherited by subclasses. If a base class is frozen, any subclass must also be frozen. This is a logical requirement; if a subclass were mutable, it could break the immutability guarantee of the base class by adding mutable fields or allowing base class fields to be modified.
from dataclasses import dataclass
@dataclass(frozen=True)
class ImmutableBase:
base_value: int
# This subclass MUST be frozen as well.
@dataclass(frozen=True)
class ImmutableDerived(ImmutableBase):
derived_value: str
obj = ImmutableDerived(base_value=42, derived_value="hello")
# Both base and derived fields are immutable
try:
obj.derived_value = "world"
except Exception as e:
print(f"Error: {type(e).__name__}") # Output: Error: FrozenInstanceError
Attempting to create a non-frozen subclass from a frozen base class will result in a ValueError at class definition time, preventing this inconsistency from occurring. The reverse scenario—a frozen subclass from a non-frozen base—is permitted but is often a code smell, as it imposes a stricter contract on the subclass than the base class provides.