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​