Skip to main content

Frozen Dataclasses: Immutable Data Modeling in Python

A frozen dataclass is immutable: once created, its fields cannot be changed. Setting frozen=True prevents attribute assignment, automatically generates __hash__, and makes the dataclass suitable as a dictionary key or set member. Frozen dataclasses are ideal for values, configuration snapshots, and multi-threaded code where immutability is a safety feature.

I've used frozen dataclasses extensively in concurrent systems where accidental mutation of shared state is a silent killer. Making them immutable at the type level catches bugs before they ship.

Creating a Frozen Dataclass

Simply add frozen=True to the @dataclass decorator:

from dataclasses import dataclass

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

p = Point(10, 20)
print(p.x) # 10

# Attempt to mutate raises FrozenInstanceError
try:
p.x = 15
except AttributeError as e:
print(f"Error: {e}") # Error: 'Point' object attribute 'x' is read-only

Frozen dataclasses generate __setattr__ and __delattr__ that raise FrozenInstanceError (a subclass of AttributeError). The assignment fails immediately at runtime, preventing silent bugs.

Automatic Hashing

Frozen dataclasses are hashable by default, making them usable as dictionary keys or in sets:

from dataclasses import dataclass

@dataclass(frozen=True)
class Color:
red: int
green: int
blue: int

colors = {
Color(255, 0, 0), # red
Color(0, 255, 0), # green
Color(0, 0, 255), # blue
}

print(len(colors)) # 3

# Use as dict key
color_names = {
Color(255, 0, 0): "red",
Color(0, 255, 0): "green",
Color(0, 0, 255): "blue",
}

print(color_names[Color(255, 0, 0)]) # red

The generated __hash__ is based on the field values. Two frozen dataclass instances with identical fields have the same hash and are equal.

Hashability Constraints

For a frozen dataclass to be hashable, all its fields must be hashable (immutable). Lists, dicts, and sets are unhashable:

from dataclasses import dataclass

# This is valid (all fields are immutable)
@dataclass(frozen=True)
class Point:
x: float
y: float

# This raises a TypeError: unhashable field
# @dataclass(frozen=True)
# class Configuration:
# name: str
# options: dict # unhashable!

# Workaround: use a tuple or frozenset instead
@dataclass(frozen=True)
class SafeConfig:
name: str
options: tuple[tuple[str, str], ...] = () # hashable tuple of tuples

If you have unhashable fields and need a frozen dataclass, exclude them from hashing:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Settings:
name: str
tags: frozenset[str] = field(default_factory=frozenset)

settings = Settings(name="prod", tags=frozenset(["critical", "cached"]))
config_set = {settings} # Works!

Thread-Safety with Frozen Dataclasses

Immutability is a powerful concurrency primitive. Frozen dataclasses cannot be mutated, eliminating race conditions:

from dataclasses import dataclass
from threading import Thread
import time

@dataclass(frozen=True)
class Config:
host: str
port: int
timeout: float

config = Config("localhost", 8000, 30.0)

def worker(cfg: Config) -> None:
# cfg is guaranteed immutable; safe to read without locks
print(f"Connecting to {cfg.host}:{cfg.port}")
time.sleep(0.1)
print(f"Still {cfg.host}; no mutation possible")

threads = [Thread(target=worker, args=(config,)) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()

Since config is frozen, all threads safely read its fields without synchronization. No locks needed.

Computed (Derived) Fields in Frozen Dataclasses

You can add computed fields using field(init=False) and __post_init__:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Rectangle:
width: float
height: float
area: float = field(init=False)

def __post_init__(self) -> None:
if self.width <= 0 or self.height <= 0:
raise ValueError("dimensions must be positive")
# Use object.__setattr__ to assign to frozen instance
object.__setattr__(self, 'area', self.width * self.height)

rect = Rectangle(10, 20)
print(rect.area) # 200.0

# Attempted mutation fails
try:
rect.area = 100
except AttributeError as e:
print(f"Error: {e}")

The trick here is using object.__setattr__() to bypass the frozen class's immutability just during initialization. After __post_init__, the instance is truly frozen.

Frozen vs. Mutable: Design Trade-Offs

AspectFrozenMutable
MutabilityImmutable after creationCan change fields
HashableYes (all fields hashable)No
Thread-safeYes, no locks neededNo, synchronization required
FlexibilityLimited; can't adjust stateFull flexibility
PerformanceSlightly faster hash/equalityComparable
MemoryGenerates __hash__~8 bytes overhead

Use frozen when safety and immutability are priorities. Use mutable when you need to update state during an object's lifetime.

Conversion: Frozen to Mutable

To create a mutable copy from a frozen dataclass, use dataclasses.replace():

from dataclasses import dataclass, replace

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

p1 = Point(10, 20)

# Create a mutable dataclass from the frozen one
@dataclass
class MutablePoint:
x: float
y: float

p2 = MutablePoint(x=p1.x, y=p1.y)
p2.x = 15 # Now mutable

# Or use replace() to create a new frozen instance with some fields changed
p3 = replace(p1, x=15) # Returns new frozen Point(15, 20)
print(p3) # Point(x=15, y=20)
print(p1) # Point(x=10, y=20) -- original unchanged

replace() is useful for creating variations of immutable values, common in functional-style code.

Value Objects Pattern

Frozen dataclasses are perfect for value objects in domain-driven design:

from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str

def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("amount cannot be negative")
if len(self.currency) != 3:
raise ValueError("currency must be 3-letter code")

def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("cannot add different currencies")
return Money(self.amount + other.amount, self.currency)

usd_10 = Money(Decimal("10.00"), "USD")
usd_20 = Money(Decimal("20.00"), "USD")
usd_30 = usd_10 + usd_20

print(usd_30) # Money(amount=Decimal('30'), currency='USD')

# Use in sets
wallet = {usd_10, usd_20}
print(len(wallet)) # 2

Value objects are immutable, equal by value (not identity), and often implement operators. Frozen dataclasses are an excellent foundation.

Frozen Dataclass Limitations

  • You cannot add optional fields after non-optional ones in frozen dataclasses (field ordering rules still apply).
  • All fields must be hashable for the class to be hashable.
  • You cannot dynamically add attributes at runtime.
  • Initialization logic must happen in __post_init__; you cannot defer setup.

Key Takeaways

  • frozen=True prevents field assignment and automatically generates __hash__.
  • Frozen dataclasses are thread-safe: immutable state requires no synchronization.
  • Use as dictionary keys or in sets; they are hashable if all fields are hashable.
  • Computed fields use object.__setattr__() during __post_init__.
  • Frozen dataclasses are ideal for value objects, configuration snapshots, and concurrent code.
  • Use replace() to create modified copies of frozen instances.

Frequently Asked Questions

Can I freeze only some fields?

Not directly. frozen=True freezes the entire class. Use composition: a mutable dataclass with a frozen dataclass field will protect that part.

What is the performance cost of frozen vs. mutable?

Negligible. Frozen dataclasses use object.__setattr__ which is very fast. Hashing adds ~1% overhead per operation.

Can I inherit from a frozen dataclass?

Yes. The child will also be frozen (because it inherits the immutability mechanism). If you want a mutable child, that's not recommended; better to use composition.

Do I need to import FrozenInstanceError?

No, it's raised automatically by the generated methods. You can catch AttributeError instead, which is the base class.

Further Reading