In Python, objects are passed by reference. Assigning cart = user_cart does not copy the object; it aliases the same memory. A function that mutates its argument is mutating the caller’s data. Here process_cart drops the cheapest item from the shared cart to apply a discount, and the user’s own cart silently loses the item.
In Rust, mutating through a shared reference is a compile error; in Python it runs, and the caller’s data changes under it. Mutable shared objects make this kind of drift cheap to introduce and expensive to find.
Making the data structures in a domain layer read-only is the standard countermeasure, and Python offers primitives to do exactly that. They differ in ways that bite under load: type validation at construction, memory footprint per instance, and whether the instance still carries a __dict__ that allows mutating the object’s values.
State drift and side effects
Let’s start with a simple example to demonstrate a state modification bug:
class Cart: def __init__(self, items: list[float]) -> None: self.items: list[float] = items
def process_cart(cart: Cart) -> float: # Business logic: apply a discount by dropping the cheapest item. cheapest_item = min(cart.items) cart.items = [item for item in cart.items if item != cheapest_item] return sum(cart.items)
user_cart = Cart([10, 50, 100])total_to_pay = process_cart(user_cart) # 150print(user_cart.items) # [50, 100] -- the $10 item is goneBecause Python passes objects by reference, process_cart replaces items on the caller’s instance. The user’s cart no longer holds what they put in it, and the [50, 100] state is now stored back. Any other part of the application relying on the cart’s original contents will fail.
Preventing this state drift requires objects that:
- Prevent attributes from being overwritten 1
AttributeErroron write . - Prevent mutable nested structures 2 like lists or dicts from being modified in-place.
- Support safe object copying/cloning with selective overrides.
dataclasses.replace() and msgspec.structs.replace() implement. The same type stays the same across layers: one shape for JSON, one shape for the domain. Making domain entities immutable (frozen) prevents structural drift and data corruption. If an entity’s state changes, the code must return a brand-new instance. This prevents:
- Accidental side effects: modifying an entity that is referenced elsewhere in the application memory.
- State corruption: bypassing entity-specific validation logic when modifying attributes.
- Thread/async safety issues: in concurrent code, immutable data avoids race conditions because it cannot be modified while being read.
Making a value object safe
The stdlib frozen dataclass covers the immutability at the domain layer. msgspec.Struct adds wire-boundary runtime validation with the best performance.
The mutable default
The starting point is what almost every codebase has: a mutable class with a basic constructor and no validation.
class Product: def __init__(self, sku: str, price: float) -> None: self.sku = sku self.price = priceThe class compiles, instances are tiny, and every test passes. It also accepts Product(sku="", price=-1.0) without complaint. Downstream code now has to either re-validate on every read or accept that bad data is in the system.
But frozen=True is not enough
The first move is to lock the attributes. The Python docs explain how frozen=True blocks obj.x = 1 by generating a custom __setattr__:
from dataclasses import dataclass
@dataclass(frozen=True)class Product: sku: str price: floatAny attempt to modify a Product instance attribute now raise FrozenInstanceError. But you can still modify an instance with obj.__dict__["price"] = -1.0 (frozen=True only intercepts the attribute writes, but not the internal dictionary access). A misbehaving library that uses obj.__dict__.update(...) (and several do) bypasses the guard.
Closing the back door with slots=True
slots=True tells the dataclass decorator to generate a __slots__ tuple instead of __dict__, eliminating the bypass. You can then use the __post_init__ function to run the domain cross-field validation logic, that will be executed right after __init__:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)class Product: sku: str price: float
def __post_init__(self) -> None: if self.price <= 0.0: raise ValueError("Price must be greater than zero")The dataclass is usually enough for the inner domain layer. It is stdlib-only, has a small validation memory footprint, and supports pickle, copy, and deepcopy. The limitation is that it does not validate types at runtime: passing price="free" to a float field is a no-op as far as the dataclass is concerned. That’s the right trade-off in the domain layer, where the static checker owns types; at a wire boundary, the input isn’t trusted enough to skip runtime validation.
But it’s still not enough outside the domain layer
That blind spot sits exactly where the input is least trustworthy. Product(sku="", price=-1.0) constructs without complaint, and so does Product(sku="", price="free"): the dataclass does not validate types at runtime. At a wire boundary the static types are advisory at best, because the bytes come from an HTTP client, a Kafka producer, or a Redis writer, not from your type checker.
Use pydantic for the ecosystem
pydantic validates at construction, which inverts the trade-off. At a wire boundary, untrusted input failing fast is a fine property. A BaseModel with model_config = {"frozen": True} blocks writes, and Field(gt=0) validates the price:
from pydantic import BaseModel, Field
class Product(BaseModel): model_config = {"frozen": True}
sku: str price: float = Field(gt=0.0)It is the right call when the ecosystem integration matters: a FastAPI service (request/response validation and OpenAPI schema derive from pydantic models), a codebase that needs JSON Schema output, or an existing stack already invested in the pydantic tooling (SQLModel, Litestar, shared schemas). model_validate_json parses and validates in a single pass.
The cost is that the same re-validation runs when you construct an object from data your own system already vetted, and the attrs team’s critique stands for business objects: the shape of your web API applies design pressure to your domain model, and every object read from a trusted database gets re-validated unnecessarily. Pydantic also carries the heaviest footprint of the three: its class holds schema and validator objects on top of every instance. For a stack you control end to end, msgspec covers the same ground with one type.
Use msgspec where performance matters
msgspec.Struct supports both runtime checks and immutability with a very clean API and minimum overhead. It is a C-extension type that generates __init__, __eq__, __hash__, and __repr__, carries no __dict__, and can be declared frozen=True:
from typing import Annotated
from msgspec import Meta, Struct, json
class Product(Struct, frozen=True): sku: Annotated[str, Meta(min_length=1, pattern=r"^[A-Z0-9-]+$")] price: Annotated[float, Meta(gt=0)]At the wire boundary, decoding validates the annotations in a single pass:
product = json.decode(raw_bytes, type=Product) # ValidationError on bad inputpayload = json.encode(product) # and back out againConstraints live in the type annotations (Meta). Cross-field invariants go in __post_init__, which msgspec also runs after decoding.
The property that lets the same Struct serve every layer is that validation runs at decode, not at construction. Building Product("A-1", 12.5) is as cheap as the dataclass. There is no re-validation of data you already trust. msgspec.structs.replace(product, price=15.0) returns a new instance for state transitions.
Just as with pydantic, the built in json decode function validates and parses in one pass, and the same frozen Struct blocks attribute writes, has no __dict__, and transitions via replace().
On hot paths, msgspec’s own published benchmarks report Structs roughly 4x faster to create, 4x to 30x faster on equality, and 5x to 60x faster on order than a frozen slots dataclass.
Other alternatives
There are other production-grade libraries, and each solves a real problem. But each solves only half of this one, and the pair you would need to cover both halves is heavier than msgspec alone.
attrsis a class-building toolkit, not a validator.@attrs.frozengives you immutability, but the attrs documentation is explicit that it “does not try to be a validation library.” Its validators are hand-written calls that run on every construction. For a domain value object with a few invariants that is fine, but it does not parse or validate untrusted JSON; that work is left to you.cattrsis attrs’ official companion for exactly that structuring and validation work. The catch is that it validates as a second pass after decoding: the bytes become Python objects first, then cattrs walks the whole structure again converting and validating. msgspec’s published decode-and-validate benchmark shows the single-pass approach roughly 10x faster than cattrs for the same work. If you would need attrs plus cattrs to match whatmsgspecdoes with one type, the pair is the heavier option.
Takeaways
The decision comes down to the following, depending on the nature of the models:
- Simple objects or domain models: use
@dataclass(frozen=True, slots=True), and wire up__post_init__for invariants. - Reach for
pydanticwhen the ecosystem integration matters (FastAPI, JSON Schema output, or an existing pydantic codebase); its construction-time validation fits a wire boundary and the OpenAPI generation is well worth the overhead. - If you need better performance or advanced features, use
msgspec.Structfor both the wire boundary and the domain layer: the same frozen type decodes, validates, and encodes at the boundary, then stays cheap and immutable in the domain. - The rest fall away:
attrsdoes not validate on its own,cattrsvalidates as a second pass after decoding, andNamedTupleis too simple for anything but the smallest value objects. And they all run slower thanmsgspec.
When not to use frozen immutability
Frozen isn’t free. Every time you change an object’s state, you have to build a new instance instead of editing the old one, which can have a big impact in performance in certain situations:
Tight numeric buffers. If you’re updating a large array in place, say 10 million floats per frame in a simulation, copying the whole buffer just to change one value is wasteful. There’s no sharing surface here: nothing else holds a reference to the buffer, so there’s nothing to protect.
ORM entities. An ORM row has an identity. When you call db.session.add(user), the session tracks that specific object and re-reads it on update. frozen=True collides with that lifecycle: the ORM needs to write attributes as it syncs state, and a frozen object refuses. In DDD, keep the ORM entity and the domain model as separate classes, and map between them in the repository layer.
Transient scratch objects. If a value is created and consumed in the same frame, a loop counter or a temporary accumulator, no other code can ever see it. Freezing it buys you nothing.
The deciding question is ownership. If nothing holds a long-lived reference to the object, frozen gains you nothing.
Errors to keep track of
If a service that previously emitted zero FrozenInstanceError starts raising them, a caller is mutating a value object it should treat as read-only, most often a misbehaving test that pokes obj.x = 1 instead of dataclasses.replace() or msgspec.structs.replace().
A spike in TypeError: unhashable type from objects that used to hash cleanly means a list[str] field slipped in where a tuple[str, ...] was required.
__hash__ stays stable, so a mutable object can be hashable if it hashes on immutable identity (e.g. hash(self) returning hash(self._id) for an immutable uuid field). What Python forbids is hashing by mutable value: built-in containers set __hash__ = None, making any value-based lookup safe by construction.