Immutability patterns
NamedTuple — Immutable Records
Section titled “NamedTuple — Immutable Records”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 unchangedp3 = p2.scale(2)
print(p1) # Point(x=1, y=2) ← original untouchedprint(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) ← unchangedprint(dev_cfg) # Config(host='localhost', port=3000, debug=True)
# Attempting mutation raises an errorcfg.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.
Immutable Dict Operations
Section titled “Immutable Dict Operations”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 99updated = {**original, "b": 99}
# add a new keyextended = {**original, "d": 4}
# remove a key — using comprehensionreduced = {k: v for k, v in original.items() if k != "a"}
print(original) # {'a': 1, 'b': 2, 'c': 3} ← untouched in all casesprint(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.
Choosing the Right Tool
Section titled “Choosing the Right Tool”NamedTuple | @dataclass(frozen=True) | Dict unpacking | |
|---|---|---|---|
| Immutable | ✅ | ✅ | ✅ by convention |
| Default values | ❌ | ✅ | ✅ |
| Methods | ✅ | ✅ | ❌ |
| Lightweight | ✅ | ⚠️ | ✅ |
| Best for | Simple records | Rich config objects | Ad-hoc data transforms |