Back to Blog
Python

Python Frozen Dataclass vs Immutable Object

Compare Python frozen dataclasses with manually implemented immutable objects: what each guarantees about mutation, hashing, and performance, and when to choose one.

dataclassesimmutabilitypythonfrozenobject design
Illustration comparing a frozen dataclass and an immutable object in Python, showing a locked container with nested mutable elements

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 handle equality and hashing, and how they perform. This article compares the two approaches 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 assigning to a dataclass field 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 prevents assignment to the dataclass’s fields. 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 fields, 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 attribute rebinding 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 copy mutable values in __init__ so later changes to caller-supplied objects are not visible. 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 examples prevent direct assignment to the object’s fields, 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 copy mutable fields in __init__, which prevents external callers from mutating the stored values through their own references:

from copy import deepcopy class SafeBag: def __init__(self, items): object.__setattr__(self, 'items', deepcopy(items)) def __setattr__(self, name, value): raise AttributeError('immutable') original = [1, 2] b = SafeBag(original) original.append(3) # does not affect b.items b.items.append(3) # b.items is still mutable and can be changed in place

Even with deepcopy, the items attribute itself is a mutable list. The object is immutable only in the sense that you cannot rebind 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 a value-based __hash__ automatically. You must implement __eq__ and __hash__ yourself. If you define __eq__ without also defining __hash__, Python marks the class unhashable, so instances cannot be used in sets or as dict keys. If you implement neither method, the class uses default identity hashing, which is usually not what you want for value objects. 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. Preventing field rebinding covers part of that requirement, but it does not freeze mutable field contents. A field such as a list usually makes a frozen dataclass unhashable, because hashing the dataclass calls hash() on the list and raises TypeError. If a field is a custom mutable object that also implements __hash__, changing that field after the dataclass is created can change the dataclass’s hash and break dict/set behavior. Custom immutable objects that copy mutable fields avoid external mutation of the supplied values, but only if the stored 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 generally small in normal code. 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 usually small enough not to matter 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:

  • Defensive copies of mutable fields in the constructor, so later changes to the caller’s original values do not appear through the object.
  • 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 expecting a frozen dataclass with a list field to be usable as a dict key. The generated hash calls hash() on each field, and a list is unhashable, so this raises TypeError. Use an immutable field such as a tuple (containing only hashable values) if you need hash-based lookup. Also avoid mutable-but-hashable fields: if such an object’s hash can change after the dataclass is created, it breaks the object’s hash contract.

Inheritance is another area to be careful about. The frozen parameter is not itself inherited by a subclass, but the generated __setattr__ method from the frozen base class is inherited. A non-frozen subclass will therefore normally still raise FrozenInstanceError on field assignment. The protection is method-based, not a language-level freeze. If a subclass defines its own __setattr__, it can override that behavior. For custom immutable objects, the same method-based behavior applies: an inherited __setattr__ override carries over unless the subclass overrides it.

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 that use object.__setattr__ is a better fit.

Frozen Dataclass vs Immutable Object in Python: How to Choose | RYUSLOG DEV