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
| Aspect | Frozen | Mutable |
|---|---|---|
| Mutability | Immutable after creation | Can change fields |
| Hashable | Yes (all fields hashable) | No |
| Thread-safe | Yes, no locks needed | No, synchronization required |
| Flexibility | Limited; can't adjust state | Full flexibility |
| Performance | Slightly faster hash/equality | Comparable |
| Memory | Generates __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=Trueprevents 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.