Skip to content

Immutability patterns

A NamedTuple is a lightweight, immutable data structure. Once created, its fields cannot be reassigned. Operations that would “modify” it instead return a new instance with the changes applied:

from typing import NamedTuple
class Point(NamedTuple):
x: float
y: float
def translate(self, dx, dy):
return Point(self.x + dx, self.y + dy) # returns a NEW point
def scale(self, factor):
return Point(self.x * factor, self.y * factor) # returns a NEW point
p1 = Point(1, 2)
p2 = p1.translate(3, 4) # p1 is unchanged
p3 = p2.scale(2)
print(p1) # Point(x=1, y=2) ← original untouched
print(p2) # Point(x=4, y=6)
print(p3) # Point(x=8, y=12)

Because translate and scale always return new Point instances, calls can be chained naturally and the original is never at risk of being modified.


Frozen Dataclass — Immutability with More Features

Section titled “Frozen Dataclass — Immutability with More Features”

When you need more structure than a NamedTuple — default values, methods, derived fields — a @dataclass(frozen=True) gives you immutability with the full power of a class. Attempting to reassign any field raises a FrozenInstanceError at runtime:

from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False
def with_port(self, port):
return Config(self.host, port, self.debug) # returns a NEW config
def with_debug(self, debug):
return Config(self.host, self.port, debug) # returns a NEW config
cfg = Config()
dev_cfg = cfg.with_port(3000).with_debug(True) # chaining works naturally
print(cfg) # Config(host='localhost', port=8080, debug=False) ← unchanged
print(dev_cfg) # Config(host='localhost', port=3000, debug=True)
# Attempting mutation raises an error
cfg.port = 9000 # ❌ FrozenInstanceError: cannot assign to field 'port'

The with_* method pattern is the standard convention for immutable updates — each method returns a new instance with one field changed, enabling fluent chaining.


Python dicts are mutable by default, but you can work with them in an immutable style using unpacking and comprehensions — every operation produces a new dict, leaving the original untouched:

original = {"a": 1, "b": 2, "c": 3}
# update a value — b becomes 99
updated = {**original, "b": 99}
# add a new key
extended = {**original, "d": 4}
# remove a key — using comprehension
reduced = {k: v for k, v in original.items() if k != "a"}
print(original) # {'a': 1, 'b': 2, 'c': 3} ← untouched in all cases
print(updated) # {'a': 1, 'b': 99, 'c': 3}
print(extended) # {'a': 1, 'b': 2, 'c': 3, 'd': 4}
print(reduced) # {'b': 2, 'c': 3}

The ** unpacking operator is the idiomatic Python way to perform immutable dict transformations — it reads clearly, composes well, and never touches the source.


NamedTuple@dataclass(frozen=True)Dict unpacking
Immutable✅ by convention
Default values
Methods
Lightweight⚠️
Best forSimple recordsRich config objectsAd-hoc data transforms