Python Frozen Dataclass vs Immutable Object
python frozen dataclass vs immutable object: Compare Python frozen dataclasses with manually implemented immutable objects: mutation behavior, hashability, performance...
When you need an object that cannot change after creation, Python offers two common approaches: a dataclass with frozen=True and a custom immutable object. They look similar on the surface, but they differ in what they guarantee, how they behave with equality and hashing, and how they perform. This article compares python frozen dataclass vs immutable object so you can pick the right one for your use case.
What a Frozen Dataclass Actually Guarantees
A frozen dataclass is a dataclass with frozen=True in its decorator. The @dataclass(frozen=True) decorator generates a class where attribute assignment raises FrozenInstanceError after initialization. Here is a minimal example:
from dataclasses import dataclass @dataclass(frozen=True) class Point: x: int y: int p = Point(1, 2) p.x = 3 # raises dataclasses.FrozenInstanceError
The frozen flag only prevents attribute assignment. It does not make the object deeply immutable. If a field is a mutable object like a list or dict, you can still modify that object in place:
@dataclass(frozen=True) class Bag: items: list b = Bag([1, 2]) b.items.append(3) # works, no error
This is a common misconception: frozen=True is a shallow immutability guard. It stops rebinding attributes, but it does not freeze the contents of mutable fields.
How Immutable Objects Are Typically Built
A manually implemented immutable object is a class that prevents any state change after __init__. The standard technique is to override __setattr__ to raise an exception after initialization:
class ImmutablePoint: def __init__(self, x, y): object.__setattr__(self, "x", x) object.__setattr__(self, "y", y) def __setattr__(self, name, value): raise AttributeError(f"{type(self).__name__} is immutable") p = ImmutablePoint(1, 2) p.x = 3 # raises AttributeError
Because __setattr__ is overridden, the constructor must use object.__setattr__ to bypass it. This gives you full control over what immutability means. You can, for example, allow private attributes to change internally while preventing public mutation, or you can deep-freeze nested structures by copying them in __init__. The key difference from a frozen dataclass is that you decide the exact behavior; the dataclass gives you a fixed, shallow rule.
Comparing Mutation Behavior
Both approaches prevent direct attribute assignment, but they differ in edge cases. A frozen dataclass raises dataclasses.FrozenInstanceError, a subclass of AttributeError. A custom immutable object typically raises a plain AttributeError. If your code catches AttributeError broadly, the distinction may matter.
More importantly, frozen dataclasses do not block mutation of mutable fields. A custom immutable object can be designed to deep-copy mutable fields in __init__, preventing external mutation entirely:
from copy import deepcopy class SafeBag: def __init__(self, items): object.__setattr__(self, "items", deepcopy(items)) def __setattr__(self, name, value): raise AttributeError("immutable") b = SafeBag([1, 2]) b.items.append(3) # no effect on original, but b.items is still mutable
Even with deepcopy, the items attribute itself is a mutable list. The object is immutable only in the sense that you cannot reassign attributes. To get true deep immutability, you would need to use immutable containers like tuples or custom immutable list wrappers. Neither a frozen dataclass nor a simple custom class gives you that for free.
Hashability and Equality
A frozen dataclass is automatically hashable if all its fields are hashable. The generated __hash__ is based on the same fields used in __eq__. This makes frozen dataclasses usable as dictionary keys or set members without extra work:
@dataclass(frozen=True) class Point: x: int y: int p1 = Point(1, 2) p2 = Point(1, 2) print(hash(p1) == hash(p2)) # True
A custom immutable object does not get hashing for free. You must implement __hash__ and __eq__ yourself. If you forget, the object becomes unhashable by default, and you lose the ability to use it in sets or as a dict key. The dataclass also generates __repr__ and __eq__, which reduces boilerplate.
However, hashability has a subtle requirement: the hash must not change after the object is created. Both approaches enforce that by preventing attribute assignment, but if a frozen dataclass contains a mutable field, its hash can change if that field is modified. That breaks the hash contract and can corrupt dict/set behavior. Custom immutable objects that deep-copy mutable fields avoid this problem, but only if the copied fields are themselves immutable or never exposed.
Performance and Memory Considerations
Frozen dataclasses are implemented in pure Python and use __setattr__ checks. The overhead is small but measurable in tight loops. A custom immutable object with a manual __setattr__ override has similar overhead, but you can optimize it by using __slots__ to reduce memory usage and attribute lookup time:
class ImmutablePoint: __slots__ = ("x", "y") def __init__(self, x, y): object.__setattr__(self, "x", x) object.__setattr__(self, "y", y) def __setattr__(self, name, value): raise AttributeError("immutable")
Dataclasses also support slots=True in Python 3.10+, so you can get the same memory benefit:
@dataclass(frozen=True, slots=True) class Point: x: int y: int
In practice, the performance difference between a frozen dataclass and a well-written custom immutable class is negligible for most applications. The larger cost comes from deep-copying mutable fields if you choose to do that for safety. If you need maximum performance and do not need deep immutability, a frozen dataclass with slots=True is usually the best choice.
Choosing Between Frozen Dataclass and Custom Immutable Object
Use a frozen dataclass when you want a concise, readable data container with automatic __init__, __repr__, __eq__, and __hash__, and you only need shallow immutability. It is ideal for configuration objects, DTOs, and value objects where fields are primitives or other frozen dataclasses.
Choose a custom immutable object when you need:
- Deep immutability by copying mutable fields in the constructor.
- Custom
__setattr__behavior, such as allowing internal state changes. - Compatibility with code that expects a specific exception type.
__slots__without relying on Python version support (though dataclasses now support it).- Control over
__eq__and__hash__semantics beyond what dataclass generates.
If your object must be truly immutable at all levels, neither approach gives it to you automatically. You must ensure that every mutable field is either replaced with an immutable type or deeply copied and never exposed. The frozen dataclass is the safer default because it reduces boilerplate and enforces the most common immutability convention, but understand its limits.
Common Pitfalls with Frozen Dataclasses
One frequent mistake is assuming frozen=True also freezes nested objects. If you need deep immutability, you must recursively convert mutable fields or use immutable alternatives. Another pitfall is using a frozen dataclass with a list field as a dict key. The hash changes if the list is modified, which breaks the object's contract. You can avoid this by using a tuple field instead.
Another issue arises with inheritance. If a subclass of a frozen dataclass is not itself decorated with frozen=True, it may allow mutation. The frozen flag is not inherited automatically:
@dataclass(frozen=True) class Base: x: int @dataclass class Child(Base): y: int c = Child(1, 2) c.y = 3 # works, because Child is not frozen
If you need immutability across a class hierarchy, every subclass must be declared frozen as well. Custom immutable objects have the same issue if __setattr__ is overridden in the base class, but the override is inherited, so the protection carries over unless the subclass overrides it again.
Finally, be aware that frozen=True does not prevent mutation through methods defined on the class. You can write a method that uses object.__setattr__ to change a field, bypassing the frozen guard. This is sometimes useful for caching or lazy initialization, but it violates the immutability contract. If you need that behavior, document it clearly and consider whether a custom immutable object with explicit internal setter methods is a better fit.