Why Immutability Matters in Python Data Models
Immutability means that once an object is created, its observable state does not change. In data modeling, immutability is less about ideology and more about controlling when and how state can change. When objects cannot be modified accidentally, you get fewer bugs from shared references, safer caching, easier reasoning about code, and more predictable behavior in concurrent or asynchronous code.
In Python, “immutable” is not a single feature; it is a set of techniques and conventions. Some objects are inherently immutable (e.g., int, str, tuple), while your own models can be made effectively immutable by freezing dataclasses, using Pydantic’s frozen configuration, and designing APIs that expose controlled mutation through explicit methods.
Immutability vs. Deep Immutability
A crucial distinction is between shallow immutability and deep immutability. Shallow immutability means you cannot reassign attributes on the object, but the attributes themselves might be mutable. For example, a frozen dataclass may prevent obj.items = ..., but if items is a list, callers can still do obj.items.append(...). Deep immutability means the entire object graph is immutable, typically by using immutable container types (e.g., tuple, frozenset, MappingProxyType) or by copying/normalizing inputs into immutable representations.
Freezing Dataclasses: Shallow Immutability with Practical Tradeoffs
Dataclasses support freezing via @dataclass(frozen=True). This prevents attribute assignment after initialization. It is a strong default for “value-like” models and configuration-like objects, but you must still consider deep immutability and performance implications.
Basic Frozen Dataclass
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Money:
currency: str
amount: int
m = Money(currency="USD", amount=100)
# m.amount = 200 # raises dataclasses.FrozenInstanceErrorWith frozen=True, Python raises FrozenInstanceError on attribute assignment. This is enforced by dataclasses generating a custom __setattr__.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Step-by-step: Updating a Frozen Dataclass (Copy-on-Write)
Since you cannot mutate, you create a new instance with the updated value. Dataclasses provide dataclasses.replace to do this ergonomically.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Address:
city: str
country: str
addr = Address(city="Berlin", country="DE")
addr2 = replace(addr, city="Munich")
assert addr.city == "Berlin"
assert addr2.city == "Munich"This “copy-on-write” style is explicit: you can see where state changes happen, and you can keep old versions around for auditing or rollback.
Frozen + Mutable Fields: The Common Footgun
Freezing does not automatically freeze nested values. If you store a list or dict, callers can mutate it even though the dataclass is frozen.
from dataclasses import dataclass
@dataclass(frozen=True)
class Cart:
items: list[str]
c = Cart(items=["apple"])
c.items.append("banana") # succeeds: list is still mutableIf you want deep immutability, prefer immutable containers:
from dataclasses import dataclass
@dataclass(frozen=True)
class Cart:
items: tuple[str, ...]
c = Cart(items=("apple",))
# c.items += ("banana",) # fails because it tries to assign to itemsTo “add an item,” you create a new instance:
from dataclasses import replace
c2 = replace(c, items=c.items + ("banana",))Frozen Dataclasses and Hashing
Immutability often goes together with hashability (e.g., using objects as dict keys or set members). Frozen dataclasses can be hashable, but only if their fields are hashable. A frozen dataclass with a list field cannot be hashed.
from dataclasses import dataclass
@dataclass(frozen=True)
class Key:
parts: tuple[str, ...]
k = Key(parts=("a", "b"))
{ k: 1 } # worksIf you need hashability, design fields to be immutable and hashable (tuples, frozensets, strings, ints, enums).
Controlled Mutation: When “Immutable” Still Needs State Changes
Many models represent things that evolve. You can still keep a mostly immutable style by making mutation explicit and constrained. Instead of letting callers set attributes freely, provide methods that perform a small, validated transition and return a new instance (functional update), or mutate internal state in a tightly controlled way while keeping the public API stable.
Functional Updates with Domain Methods
A common pattern is: frozen model + methods that return a new instance. This keeps the object immutable from the outside while still supporting evolution.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Profile:
email: str
display_name: str
def with_display_name(self, new_name: str) -> "Profile":
new_name = new_name.strip()
if not new_name:
raise ValueError("display_name cannot be empty")
return replace(self, display_name=new_name)
p1 = Profile(email="a@example.com", display_name="Alice")
p2 = p1.with_display_name("Alice B")This approach has two benefits: the update is named (so it conveys intent), and validation is centralized in the method rather than scattered across the codebase.
Encapsulated Internal Mutation (Private Attributes)
Sometimes you need mutation for performance (e.g., building up a structure) but want to prevent arbitrary external changes. You can keep attributes “private” by convention and expose only safe operations. This is not enforced by the interpreter, but it is a practical technique in Python.
from dataclasses import dataclass, field
@dataclass
class EventBuffer:
_events: list[str] = field(default_factory=list, repr=False)
def add(self, event: str) -> None:
event = event.strip()
if not event:
raise ValueError("event cannot be empty")
self._events.append(event)
def snapshot(self) -> tuple[str, ...]:
return tuple(self._events)Here, mutation is allowed, but only through add, and consumers get an immutable snapshot via a tuple.
Pydantic: Freezing Models and Managing Updates
Pydantic models can also be made immutable. The exact configuration depends on the Pydantic major version, but the idea is the same: prevent attribute assignment and encourage explicit updates.
Pydantic v2: Frozen Models
from pydantic import BaseModel, ConfigDict
class UserSettings(BaseModel):
model_config = ConfigDict(frozen=True)
theme: str
page_size: int
s = UserSettings(theme="dark", page_size=50)
# s.page_size = 100 # raises a validation/assignment errorWith frozen=True, assignment is blocked. This is shallow immutability: nested mutable objects can still be mutated unless you choose immutable types.
Step-by-step: Updating a Frozen Pydantic Model
Pydantic provides a copy/update mechanism. In v2, use model_copy:
from pydantic import BaseModel, ConfigDict
class UserSettings(BaseModel):
model_config = ConfigDict(frozen=True)
theme: str
page_size: int
s1 = UserSettings(theme="dark", page_size=50)
# 1) create a new instance with changes
s2 = s1.model_copy(update={"page_size": 100})
# 2) original is unchanged
assert s1.page_size == 50
assert s2.page_size == 100If you want the update to be validated, ensure you are using Pydantic’s update mechanisms correctly for your version and settings. A safe rule of thumb is: prefer constructing a new model (or using the library’s copy/update API) rather than bypassing validation via direct attribute setting.
Deep Immutability in Pydantic: Choose Immutable Field Types
As with dataclasses, deep immutability is achieved by using immutable types for fields. Prefer tuple over list, frozenset over set, and immutable mappings where possible.
from pydantic import BaseModel, ConfigDict
class Permissions(BaseModel):
model_config = ConfigDict(frozen=True)
scopes: frozenset[str]
p = Permissions(scopes=frozenset({"read"}))
# p.scopes.add("write") # fails: frozenset has no addPatterns for Controlled Mutation
Immutability is a spectrum. The goal is to make changes deliberate and safe. The following patterns are commonly useful in real systems.
Pattern 1: “Builder” for Efficient Construction, Immutable Result
When assembling an object incrementally, repeated copying can be expensive. Use a mutable builder that produces an immutable final model.
from dataclasses import dataclass, field
@dataclass
class ReportBuilder:
_lines: list[str] = field(default_factory=list)
def add_line(self, line: str) -> None:
self._lines.append(line)
def build(self) -> "Report":
return Report(lines=tuple(self._lines))
@dataclass(frozen=True)
class Report:
lines: tuple[str, ...]This gives you efficient mutation during construction and immutability after publication.
Pattern 2: Expose Read-only Views of Internal Collections
If you must store a dict internally, you can expose a read-only view to prevent accidental external mutation. The standard library provides types.MappingProxyType.
from dataclasses import dataclass, field
from types import MappingProxyType
@dataclass
class Registry:
_items: dict[str, int] = field(default_factory=dict, repr=False)
def register(self, key: str, value: int) -> None:
if key in self._items:
raise ValueError("duplicate key")
self._items[key] = value
@property
def items(self):
return MappingProxyType(self._items)
r = Registry()
r.register("a", 1)
view = r.items
# view["b"] = 2 # TypeError: 'mappingproxy' object does not support item assignmentThis is controlled mutation: the registry mutates internally, but consumers cannot mutate its mapping directly.
Pattern 3: “Command Methods” That Mutate but Preserve Invariants
Sometimes mutation is the simplest approach, especially for long-lived objects. If you choose mutation, make it hard to do the wrong thing: keep fields non-public, provide methods that enforce rules, and avoid exposing raw mutable structures.
from dataclasses import dataclass, field
@dataclass
class RateLimiter:
_limit: int
_used: int = 0
def consume(self, n: int = 1) -> None:
if n <= 0:
raise ValueError("n must be positive")
if self._used + n > self._limit:
raise RuntimeError("limit exceeded")
self._used += n
@property
def remaining(self) -> int:
return self._limit - self._usedHere, mutation is allowed, but only through consume, which enforces constraints.
Freezing and Serialization: Practical Considerations
Immutability interacts with serialization and deserialization. A frozen model is still constructible; it’s just not assignable after construction. This is usually fine for JSON parsing, database hydration, and message decoding. The key is to ensure that your parsing step normalizes mutable inputs into immutable representations.
Step-by-step: Normalize Inputs into Immutable Containers
When you accept a list from the outside world (e.g., JSON), convert it to a tuple in your model so that the internal representation is immutable.
from dataclasses import dataclass
@dataclass(frozen=True)
class Tags:
values: tuple[str, ...]
@staticmethod
def from_iterable(values) -> "Tags":
normalized = tuple(v.strip() for v in values if v and v.strip())
return Tags(values=normalized)
incoming = [" urgent ", "", "vip"]
t = Tags.from_iterable(incoming)
assert t.values == ("urgent", "vip")This keeps the model stable even if the caller later mutates incoming.
Working Around Freezing (and Why You Usually Shouldn’t)
Python allows you to bypass many restrictions. For frozen dataclasses, you can technically use object.__setattr__ inside methods to set attributes. Dataclasses themselves use this during initialization. You can also use it to implement lazy caching, but it should be done carefully because it breaks the mental model of immutability.
Lazy Cached Computation in a Frozen Dataclass
One legitimate use case is caching a derived value that is expensive to compute. The object is still “logically immutable” because the cached value does not change the meaning of the object; it only stores a memoized result.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Document:
text: str
_word_count: int | None = field(default=None, init=False, repr=False)
def word_count(self) -> int:
if self._word_count is None:
count = len(self.text.split())
object.__setattr__(self, "_word_count", count)
return self._word_countGuidelines for this technique: keep cached fields private, ensure the cached value is deterministic from other fields, and avoid exposing the cache as part of the public state.
Choosing Between Immutable, Frozen, and Mutable Models
In practice, you will mix approaches. Use freezing when you want to prevent accidental reassignment and encourage explicit updates. Use deep immutability when you need safe sharing, hashing, or strong guarantees about object graphs. Use controlled mutation when performance or lifecycle demands it, but keep mutation behind methods that enforce rules and avoid exposing raw mutable structures.
Decision Checklist
- If instances are shared widely (passed across layers, cached, reused), prefer frozen models with immutable field types.
- If you need to use instances as dict keys or set members, ensure fields are hashable and avoid mutable containers.
- If you need frequent incremental updates, consider a mutable builder that produces an immutable final object, or use explicit “with_…” methods returning new instances.
- If you must mutate in place, encapsulate state: keep fields private, expose read-only views, and provide command methods that validate transitions.
- If you add caching to frozen objects, treat it as an internal optimization and keep it private and deterministic.
Practical Exercise: Refactor to Controlled Mutation
The following step-by-step refactor shows how to move from ad-hoc mutation to controlled mutation without rewriting everything.
Step 1: Start with a Mutable Model (Problematic)
from dataclasses import dataclass, field
@dataclass
class NotificationPreferences:
channels: list[str] = field(default_factory=list)
prefs = NotificationPreferences()
prefs.channels.append("email")
prefs.channels.append("sms")This allows any code to append arbitrary values, including duplicates or invalid entries.
Step 2: Encapsulate the Collection and Expose a Read-only Snapshot
from dataclasses import dataclass, field
@dataclass
class NotificationPreferences:
_channels: set[str] = field(default_factory=set, repr=False)
def enable(self, channel: str) -> None:
channel = channel.strip().lower()
if channel not in {"email", "sms", "push"}:
raise ValueError("unknown channel")
self._channels.add(channel)
def disable(self, channel: str) -> None:
self._channels.discard(channel)
@property
def channels(self) -> tuple[str, ...]:
return tuple(sorted(self._channels))Now the only way to change state is through enable/disable, and consumers cannot mutate the internal set.
Step 3: If You Need Immutability, Publish a Frozen Snapshot Model
from dataclasses import dataclass
@dataclass(frozen=True)
class NotificationPreferencesSnapshot:
channels: tuple[str, ...]
# usage
prefs = NotificationPreferences()
prefs.enable("email")
prefs.enable("sms")
snap = NotificationPreferencesSnapshot(channels=prefs.channels)This hybrid approach is common: a mutable working object for lifecycle operations, and an immutable snapshot for sharing, caching, or persistence boundaries.