Python Frozen Dataclasses: Immutable Data Objects

Python's dataclasses module provides a decorator-based approach to creating classes that primarily store data. The `frozen` parameter transforms these classes into immutable objects, preventing...

Key Insights

  • Frozen dataclasses provide compile-time immutability guarantees by preventing attribute reassignment, making them ideal for value objects, configuration data, and dictionary keys where mutability could cause bugs.
  • The frozen=True parameter creates shallow immutability—you cannot reassign attributes, but mutable fields like lists can still be modified internally, requiring careful design with immutable types for true immutability.
  • Frozen dataclasses automatically generate __hash__() methods, enabling their use in sets and as dictionary keys, but all fields must be hashable or you’ll get runtime errors.

Introduction to Frozen Dataclasses

Python’s dataclasses module provides a decorator-based approach to creating classes that primarily store data. The frozen parameter transforms these classes into immutable objects, preventing attribute modification after instantiation. This immutability is crucial for creating reliable, thread-safe code and avoiding entire categories of bugs.

Here’s the fundamental difference:

from dataclasses import dataclass

@dataclass
class MutablePoint:
    x: int
    y: int

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

# Regular dataclass allows mutation
mutable = MutablePoint(1, 2)
mutable.x = 10  # Works fine

# Frozen dataclass prevents mutation
immutable = ImmutablePoint(1, 2)
immutable.x = 10  # Raises FrozenInstanceError

Use frozen dataclasses when you need value objects that shouldn’t change after creation: configuration objects, coordinates, data transfer objects, or anything you’ll use as dictionary keys. Stick with regular dataclasses when you need mutability for performance-critical code that updates state frequently.

Creating Frozen Dataclasses

The syntax is straightforward—add frozen=True to the @dataclass decorator. You can combine this with other dataclass features like default values, default factories, and field metadata:

from dataclasses import dataclass, field
from typing import Optional, List
from datetime import datetime

@dataclass(frozen=True)
class User:
    user_id: int
    username: str
    email: str
    created_at: datetime = field(default_factory=datetime.now)
    role: str = "user"
    metadata: Optional[dict] = None
    
    def is_admin(self) -> bool:
        return self.role == "admin"

# Create instances
user1 = User(1, "alice", "alice@example.com")
admin = User(2, "bob", "bob@example.com", role="admin")

# Methods work normally
print(admin.is_admin())  # True

# But mutation fails
user1.role = "admin"  # FrozenInstanceError

Field factories are essential for frozen dataclasses because they create new instances for each object, avoiding shared mutable default values. However, be cautious—using field(default_factory=list) creates a mutable list, which we’ll address shortly.

Immutability Guarantees and Limitations

The frozen parameter prevents attribute reassignment, but it doesn’t create deep immutability. This is Python’s most significant gotcha with frozen dataclasses:

from dataclasses import dataclass, field
from typing import List

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

obj = ShallowlyImmutable("test", ["python", "dataclass"])

# This fails (attribute reassignment)
obj.name = "new name"  # FrozenInstanceError
obj.tags = ["new", "list"]  # FrozenInstanceError

# This works (mutating the list contents)
obj.tags.append("immutability")  # No error!
obj.tags.clear()  # Also works!

print(obj.tags)  # []

The frozen flag only prevents reassignment of the attributes themselves. The list object remains mutable, and you can modify its contents freely. This is shallow immutability.

For true immutability, use immutable types for all fields:

from dataclasses import dataclass
from typing import Tuple

@dataclass(frozen=True)
class TrulyImmutable:
    name: str
    tags: Tuple[str, ...]  # Use tuple instead of list
    metadata: frozenset  # Use frozenset instead of set
    
obj = TrulyImmutable("test", ("python", "dataclass"), frozenset({"key"}))
# Now there's no way to mutate the object

Hashability and Collections

Frozen dataclasses automatically generate a __hash__() method, making them hashable and usable in sets and as dictionary keys:

from dataclasses import dataclass
from typing import Set, Dict

@dataclass(frozen=True)
class Coordinate:
    x: int
    y: int
    z: int = 0

# Use in sets
points: Set[Coordinate] = {
    Coordinate(0, 0),
    Coordinate(1, 1),
    Coordinate(0, 0),  # Duplicate, will be ignored
}
print(len(points))  # 2

# Use as dictionary keys
distance_cache: Dict[Coordinate, float] = {
    Coordinate(0, 0): 0.0,
    Coordinate(3, 4): 5.0,
}

point = Coordinate(3, 4)
print(distance_cache[point])  # 5.0

Critical requirement: all fields must be hashable. If any field is unhashable (like a list or dict), you’ll get a TypeError at runtime:

from dataclasses import dataclass
from typing import List

@dataclass(frozen=True)
class BadHashable:
    name: str
    items: List[str]  # Lists are not hashable!

obj = BadHashable("test", ["a", "b"])
hash(obj)  # TypeError: unhashable type: 'list'

# This also fails
my_set = {obj}  # TypeError

The solution is using tuples or other hashable types for all fields when you need hashability.

Advanced Patterns

You can use __post_init__() with frozen dataclasses, but you must use object.__setattr__() to bypass the frozen restriction during initialization:

from dataclasses import dataclass
from typing import Tuple

@dataclass(frozen=True)
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)
    
    def __post_init__(self):
        # Must use object.__setattr__() in frozen dataclasses
        object.__setattr__(self, 'area', self.width * self.height)

rect = Rectangle(10, 20)
print(rect.area)  # 200.0
rect.area = 100  # Still raises FrozenInstanceError after init

For nested immutable structures, compose frozen dataclasses:

from dataclasses import dataclass
from typing import Tuple

@dataclass(frozen=True)
class Address:
    street: str
    city: str
    country: str

@dataclass(frozen=True)
class Person:
    name: str
    age: int
    addresses: Tuple[Address, ...]  # Tuple of frozen dataclasses
    
    @classmethod
    def create(cls, name: str, age: int, address_data: list):
        addresses = tuple(Address(**addr) for addr in address_data)
        return cls(name, age, addresses)

person = Person.create(
    "Alice",
    30,
    [
        {"street": "123 Main", "city": "NYC", "country": "USA"},
        {"street": "456 Oak", "city": "LA", "country": "USA"}
    ]
)

# Fully immutable structure
person.name = "Bob"  # FrozenInstanceError
person.addresses[0].city = "Boston"  # FrozenInstanceError

Performance and Best Practices

Frozen dataclasses have minimal performance overhead compared to regular dataclasses. The primary cost is the hash computation for hashable instances:

from dataclasses import dataclass
import timeit

@dataclass
class Mutable:
    x: int
    y: int
    z: int

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

# Creation performance (similar)
mutable_time = timeit.timeit(lambda: Mutable(1, 2, 3), number=1000000)
frozen_time = timeit.timeit(lambda: Frozen(1, 2, 3), number=1000000)

print(f"Mutable: {mutable_time:.4f}s")
print(f"Frozen: {frozen_time:.4f}s")
# Difference is negligible

# Hashing adds overhead
frozen_obj = Frozen(1, 2, 3)
hash_time = timeit.timeit(lambda: hash(frozen_obj), number=1000000)
print(f"Hash: {hash_time:.4f}s")

When to choose frozen dataclasses:

  • Value objects that represent immutable concepts (dates, coordinates, money)
  • Configuration objects that shouldn’t change
  • Dictionary keys or set members
  • Multi-threaded environments where shared state needs protection
  • Domain models where immutability prevents bugs

When to choose alternatives:

  • NamedTuple: When you need immutability with minimal memory overhead and don’t need methods
  • Regular dataclasses: When you need mutability for performance or simplicity
  • Regular classes: When you need complex behavior or inheritance hierarchies

Common pitfalls:

  1. Forgetting about shallow immutability: Always use immutable types for fields
  2. Performance assumptions: Don’t assume frozen is slower—measure first
  3. Mixing mutable and frozen: Be consistent in your codebase
  4. Over-engineering: Don’t make everything frozen; use it where immutability adds value

Conclusion

Frozen dataclasses provide a powerful tool for creating immutable data objects in Python. They prevent attribute reassignment, automatically support hashing, and integrate seamlessly with Python’s type system. The shallow immutability limitation requires careful field type selection, but this is a reasonable trade-off for the safety and clarity they provide.

Use frozen dataclasses when immutability makes your code more reliable: value objects, configuration data, dictionary keys, and domain models. Combine them with immutable field types (tuples, frozensets, strings, numbers) for truly immutable structures. The minimal performance overhead and significant bug prevention make them an excellent choice for modern Python applications.

The decision to use frozen dataclasses should be driven by your domain model and safety requirements, not performance concerns. When you need an object that shouldn’t change after creation, frozen dataclasses are the Pythonic solution.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.