Skip to main content

Immutability Strategies - Tuples, Frozen Dataclasses, and Value Objects

Reading time: ~28 minutes | Level: Intermediate → Engineering

Before reading further, predict the output of this program:

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
x: float
y: float
tags: list # mutable!

p = Point(1.0, 2.0, ["origin"])
p.tags.append("modified") # raises? or succeeds?
print(p.tags) # ?
Show Answer
['origin', 'modified']

p.tags.append("modified") succeeds. No exception is raised. frozen=True prevents rebinding - you cannot do p.tags = ["something_else"] (that would raise FrozenInstanceError). But it does not prevent mutating an object that p.tags already points to. The list object itself is mutable; frozen=True only freezes the reference. This is shallow immutability. The Point is frozen at the attribute-reference level, but the objects those attributes point to can still be mutated. Understanding this distinction is the foundation of every immutability strategy in Python.

This lesson maps every tool in Python's immutability toolkit, explains exactly what each one does and does not guarantee, and shows you how to build genuinely immutable structures for use in functional programming, concurrent systems, and domain-driven design.

What You Will Learn

  • Python's mutability model: which built-in types are mutable and which are immutable
  • Why immutability matters: predictability, hashability, thread safety, caching, debugging
  • The shallow vs deep immutability distinction - the key to understanding frozen=True
  • tuple, frozenset, and bytes as truly immutable built-ins (with caveats)
  • collections.namedtuple and typing.NamedTuple as immutable named records
  • dataclasses.dataclass(frozen=True): what it does and what it does not do
  • types.MappingProxyType: read-only views of dicts
  • Strategies for "modifying" immutable structures: dataclasses.replace(), _replace() for namedtuples
  • Deep immutability: the frozen dataclass + tuple of tuples pattern
  • Real-world: value objects in Domain-Driven Design, Redux-style state in Python

Prerequisites

  • Lesson 07 (Pure Functions) - immutability is the structural enforcement of purity at the data level
  • Basic familiarity with dataclasses and collections.namedtuple
  • Understanding of Python's object model: names are references, not boxes

Part 1 - Python's Mutability Model

The Core Distinction

Every Python object is either mutable (its internal state can change after creation) or immutable (its internal state is fixed at creation and can never change).

Why Mutability Matters in Practice

# Mutable default: sharing a reference is dangerous
a = [1, 2, 3]
b = a # b and a point to the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] - a was modified through b

# Immutable: sharing a reference is safe
x = (1, 2, 3)
y = x # y and x point to the same tuple
# There is no operation that can modify x through y - sharing is safe

Mutability creates action at a distance: changes through one name affect all other names bound to the same object. Immutability eliminates this class of bug entirely.

Hashability: The Dict Key Requirement

Immutable objects are generally hashable; mutable objects generally are not.

# Immutable - hashable - can be used as dict keys and set members
d = {}
d[(1, 2)] = "point" # tuple key: works
d[frozenset({1, 2})] = "set" # frozenset key: works
d["name"] = "value" # str key: works

# Mutable - not hashable - cannot be used as dict keys
try:
d[[1, 2]] = "list key" # TypeError: unhashable type: 'list'
except TypeError as e:
print(e)

try:
d[{"a": 1}] = "dict key" # TypeError: unhashable type: 'dict'
except TypeError as e:
print(e)

This matters whenever you need composite keys - for example, indexing a grid by (row, col), or caching results keyed by a set of tags.

Part 2 - Tuples: Immutable Sequences (With a Caveat)

Tuples are the most commonly used immutable sequence in Python. They are immutable in the sense that you cannot add, remove, or replace their elements. But they can contain mutable objects.

# Tuple is immutable at the reference level
t = (1, [2, 3], "hello")

# Cannot rebind elements:
try:
t[0] = 99 # TypeError: 'tuple' object does not support item assignment
except TypeError as e:
print(e)

# CAN mutate objects inside:
t[1].append(4)
print(t) # (1, [2, 3, 4], 'hello') - the tuple changed in content!

This is the same shallow immutability you saw in the opening puzzle. The tuple guarantees that t[0], t[1], and t[2] will always refer to the same objects. It does not guarantee that those objects are themselves immutable.

Hashability of Tuples

A tuple is hashable only if all its elements are hashable.

hash((1, 2, 3)) # works - all ints
hash((1, "hello", 3.14)) # works - all hashable types
hash((1, [2, 3])) # TypeError: unhashable type: 'list'

This is why the opening puzzle's Point.tags: list breaks deep immutability even with frozen=True: a list is unhashable, so Point itself would not be hashable.

note

Python strings are immutable. str.replace(), str.upper(), str.strip() - every string method that appears to "modify" a string actually returns a new string object. The original string is never modified. This is why you must always capture the return value: name = name.strip(), not just name.strip().

Part 3 - frozenset: The Truly Immutable Set

frozenset is the immutable counterpart to set. It supports all read operations (in, len, iteration, set algebra) but not write operations (add, remove, discard, update).

tags = frozenset({"python", "functional", "immutable"})

# Read operations work:
print("python" in tags) # True
print(len(tags)) # 3
print(tags | frozenset({"new"})) # frozenset({'python', 'functional', 'immutable', 'new'})

# Write operations raise AttributeError:
try:
tags.add("another")
except AttributeError as e:
print(e) # 'frozenset' object has no attribute 'add'

# frozenset is hashable - usable as dict key:
cache = {}
cache[frozenset({"a", "b"})] = "result"
print(cache[frozenset({"b", "a"})]) # "result" - frozensets are equal regardless of order
tip

Use frozenset and tuple as dict keys when you need composite or collection-valued keys. For example, caching results of a function that takes a set of feature flags: cache[frozenset(active_flags)] = result. A regular set cannot be used as a dict key; frozenset solves this.

Part 4 - namedtuple and typing.NamedTuple

Named tuples provide immutable records with named fields. They are tuples under the hood - all tuple guarantees apply.

collections.namedtuple (Classic API)

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(x=3.0, y=4.0)

print(p.x) # 3.0
print(p[0]) # 3.0 - indexable like a tuple
print(p._fields) # ('x', 'y')

# _replace returns a NEW namedtuple with specified fields changed:
p2 = p._replace(x=10.0)
print(p) # Point(x=3.0, y=4.0) - original unchanged
print(p2) # Point(x=10.0, y=4.0) - new object

typing.NamedTuple (Modern API, Preferred)

from typing import NamedTuple

class Point(NamedTuple):
x: float
y: float
label: str = "unnamed" # default value supported

p = Point(x=1.0, y=2.0)
print(p) # Point(x=1.0, y=2.0, label='unnamed')
print(p.label) # 'unnamed'

# Supports type annotations, defaults, and is inspectable:
p2 = p._replace(label="origin")
print(p2) # Point(x=1.0, y=2.0, label='origin')

# Is a tuple:
print(isinstance(p, tuple)) # True
print(hash(p)) # hashable - can be used as dict key

typing.NamedTuple is preferred over collections.namedtuple because it supports type annotations, IDE auto-completion, and mypy type checking natively.

Part 5 - dataclasses.dataclass(frozen=True)

frozen=True converts a dataclass into an immutable-ish record. Python generates __hash__ automatically and replaces __setattr__ and __delattr__ with versions that raise FrozenInstanceError.

from dataclasses import dataclass, field

@dataclass(frozen=True)
class Config:
host: str
port: int
timeout: float = 30.0
tags: tuple[str, ...] = field(default_factory=tuple)

cfg = Config(host="localhost", port=5432)

# Rebinding attributes raises FrozenInstanceError:
try:
cfg.host = "remotehost"
except Exception as e:
print(type(e).__name__, e)
# FrozenInstanceError: cannot assign to field 'host'

# Hashing works (if all fields are hashable):
print(hash(cfg)) # a stable integer hash
print({cfg: "cached result"}) # usable as dict key

The Shallow Immutability Trap

As the opening puzzle showed, frozen=True is shallow. If a field holds a mutable object, that object can still be mutated.

@dataclass(frozen=True)
class ShallowConfig:
name: str
allowed_hosts: list[str] # DANGER: mutable field

cfg = ShallowConfig(name="prod", allowed_hosts=["a.com", "b.com"])

# This raises FrozenInstanceError - cannot rebind:
# cfg.allowed_hosts = ["c.com"]

# This SUCCEEDS - mutating the list object:
cfg.allowed_hosts.append("evil.com")
print(cfg.allowed_hosts) # ['a.com', 'b.com', 'evil.com']

# And now it is no longer hashable:
try:
hash(cfg)
except TypeError as e:
print(e) # unhashable type: 'list'
warning

frozen=True is shallow immutability. It prevents rebinding of attributes but does not prevent mutation of mutable objects stored in those attributes. If you put a list, dict, or set inside a frozen=True dataclass, the field can still be mutated through its reference. For genuinely immutable data structures, use tuple instead of list, frozenset instead of set, and types.MappingProxyType instead of dict for nested fields.

Part 6 - types.MappingProxyType: Read-Only Dict Views

types.MappingProxyType wraps a dict and returns a read-only view. You cannot add, modify, or delete keys through the proxy. The underlying dict can still be modified if you have a reference to it - the proxy is a read-only view, not a deep copy.

from types import MappingProxyType

original = {"host": "localhost", "port": 5432}
proxy = MappingProxyType(original)

# Read operations work:
print(proxy["host"]) # "localhost"
print(len(proxy)) # 2
print("port" in proxy) # True

# Write operations raise TypeError:
try:
proxy["host"] = "remote"
except TypeError as e:
print(e) # 'mappingproxy' object does not support item assignment

try:
proxy["new_key"] = "value"
except TypeError as e:
print(e) # 'mappingproxy' object does not support item assignment

# But the underlying dict can still be modified:
original["host"] = "remote"
print(proxy["host"]) # "remote" - proxy reflects the change

# Creating a fully independent frozen dict (snapshot):
snapshot = MappingProxyType(dict(original)) # copy first, then proxy

Where Python Uses MappingProxyType Internally

MappingProxyType is used throughout the Python runtime:

class MyClass:
x = 10

# Class __dict__ is exposed as a MappingProxyType:
print(type(MyClass.__dict__)) # <class 'mappingproxy'>
print(MyClass.__dict__["x"]) # 10

This prevents external code from modifying class __dict__ directly, while still allowing it to be read. The same pattern is useful for configuration objects that should be readable but not writable by consumers.

Part 7 - Deep Immutability: Making It All the Way Down

Shallow immutability stops at one level. Deep immutability means every object in the entire structure is immutable. Python's type system does not enforce this automatically - you must construct it carefully.

Frozen Dataclass with All-Immutable Fields

from dataclasses import dataclass
from typing import Sequence

@dataclass(frozen=True)
class Address:
street: str
city: str
country: str

@dataclass(frozen=True)
class User:
user_id: int
name: str
email: str
address: Address # immutable - also a frozen dataclass
roles: tuple[str, ...] # immutable - tuple of strings (strings are immutable)
tags: frozenset[str] # immutable - frozenset of strings

user = User(
user_id=1,
name="Alice",
address=Address(street="123 Main St", city="London", country="GB"),
roles=("admin", "editor"),
tags=frozenset({"active", "verified"}),
)

# user is deeply immutable:
# - user.name is a str (immutable)
# - user.address is a frozen dataclass (immutable at its level)
# - user.address.city is a str (immutable)
# - user.roles is a tuple of strs (all immutable)
# - user.tags is a frozenset of strs (all immutable)

print(hash(user)) # hashable - all fields are hashable

The Tuple of Tuples Pattern

For grid or matrix data that must be immutable:

# Mutable - vulnerable to mutation
grid_mutable = [[0, 1, 0], [1, 0, 1], [0, 1, 0]]
grid_mutable[0][1] = 99 # silently mutates

# Immutable - cannot be mutated
grid_frozen = tuple(tuple(row) for row in [[0, 1, 0], [1, 0, 1], [0, 1, 0]])
try:
grid_frozen[0][1] = 99
except TypeError as e:
print(e) # 'tuple' object does not support item assignment

# And it is hashable:
print(hash(grid_frozen)) # works
danger

Never use mutable default arguments. They are created once at function definition time and shared across all calls. This is one of the oldest and most common Python bugs:

# BUG: default list is shared across ALL calls
def add_user(name: str, roles: list = []) -> dict:
roles.append("user") # mutates the shared default!
return {"name": name, "roles": roles}

print(add_user("Alice")) # {'name': 'Alice', 'roles': ['user']}
print(add_user("Bob")) # {'name': 'Bob', 'roles': ['user', 'user']} - wrong!

# FIX: use None sentinel
def add_user_fixed(name: str, roles: list | None = None) -> dict:
if roles is None:
roles = []
return {"name": name, "roles": roles + ["user"]}

The same applies to mutable default dicts, sets, and any other mutable default argument.

Part 8 - Strategies for "Modifying" Immutable Structures

Immutability means you never change an existing object - you create a new one with the desired changes. Python provides helpers for this.

dataclasses.replace() - Functional Updates for Frozen Dataclasses

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Config:
host: str
port: int
debug: bool = False

cfg = Config(host="localhost", port=5432)
print(cfg) # Config(host='localhost', port=5432, debug=False)

# Create a new Config with debug=True - original is untouched:
debug_cfg = replace(cfg, debug=True)
print(cfg) # Config(host='localhost', port=5432, debug=False)
print(debug_cfg) # Config(host='localhost', port=5432, debug=True)

# Multiple fields:
prod_cfg = replace(cfg, host="db.prod.example.com", port=5433)
print(prod_cfg) # Config(host='db.prod.example.com', port=5433, debug=False)

namedtuple._replace() - Functional Updates for NamedTuples

from typing import NamedTuple

class Point(NamedTuple):
x: float
y: float
z: float = 0.0

p = Point(x=1.0, y=2.0)
p_elevated = p._replace(z=5.0)
print(p) # Point(x=1.0, y=2.0, z=0.0)
print(p_elevated) # Point(x=1.0, y=2.0, z=5.0)

Composing Updates: The Functional Update Pipeline

from dataclasses import dataclass, replace
from typing import Callable

@dataclass(frozen=True)
class AppState:
user_id: int | None
authenticated: bool
page: str
cart_items: tuple[str, ...]

def login(state: AppState, user_id: int) -> AppState:
return replace(state, user_id=user_id, authenticated=True)

def navigate(state: AppState, page: str) -> AppState:
return replace(state, page=page)

def add_to_cart(state: AppState, item: str) -> AppState:
return replace(state, cart_items=state.cart_items + (item,))

# Initial state
initial = AppState(user_id=None, authenticated=False, page="home", cart_items=())

# Each operation returns a new state; previous states are preserved
after_login = login(initial, user_id=42)
after_navigate = navigate(after_login, page="shop")
after_add = add_to_cart(after_navigate, item="SKU-001")

print(initial.authenticated) # False - original unchanged
print(after_login.authenticated) # True
print(after_add.cart_items) # ('SKU-001',)
print(after_navigate.cart_items) # () - the navigate state has no cart item yet

This is the Python equivalent of Redux-style state management: each state is immutable, and "mutations" are expressed as functions that return new states.

Part 9 - Real-World Patterns

Value Objects in Domain-Driven Design

In DDD, a value object is an object that has no identity - it is defined entirely by its attributes. Two value objects with the same attributes are equal. Value objects must be immutable: you never "change" a value object, you replace it with a new one.

from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str

def __post_init__(self):
if self.amount < 0:
raise ValueError(f"Money amount cannot be negative: {self.amount}")
if len(self.currency) != 3:
raise ValueError(f"Currency must be a 3-letter code: {self.currency}")

def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
return Money(amount=self.amount + other.amount, currency=self.currency)

def scale(self, factor: Decimal) -> "Money":
return Money(amount=(self.amount * factor).quantize(Decimal("0.01")), currency=self.currency)

price = Money(amount=Decimal("99.99"), currency="GBP")
tax = price.scale(Decimal("0.20"))
total = price.add(tax)

print(price) # Money(amount=Decimal('99.99'), currency='GBP')
print(tax) # Money(amount=Decimal('20.00'), currency='GBP')
print(total) # Money(amount=Decimal('119.99'), currency='GBP')

# Value equality - two Money objects with the same values are equal:
m1 = Money(Decimal("10.00"), "USD")
m2 = Money(Decimal("10.00"), "USD")
print(m1 == m2) # True
print(hash(m1) == hash(m2)) # True - same hash

Configuration Objects

from dataclasses import dataclass, replace
from types import MappingProxyType

@dataclass(frozen=True)
class DatabaseConfig:
host: str
port: int
database: str
pool_size: int = 5
ssl: bool = True

@classmethod
def for_development(cls) -> "DatabaseConfig":
return cls(host="localhost", port=5432, database="dev_db", ssl=False)

@classmethod
def for_production(cls, host: str, database: str) -> "DatabaseConfig":
return cls(host=host, port=5432, database=database, pool_size=20, ssl=True)

dev_config = DatabaseConfig.for_development()
test_config = replace(dev_config, database="test_db")

print(dev_config.database) # dev_db
print(test_config.database) # test_db - original unchanged

# Expose as a read-only mapping for consumers that need dict-like access:
import dataclasses
config_view = MappingProxyType(dataclasses.asdict(dev_config))
print(config_view["host"]) # localhost

Engineering Checklist

Before moving on, verify you can answer these without looking:

  1. What is the difference between shallow and deep immutability?
  2. What does frozen=True on a dataclass actually prevent? What does it NOT prevent?
  3. When is a tuple hashable? When is it not?
  4. What is frozenset and why is it useful as a dict key?
  5. How do you "modify" a frozen dataclass? What function do you use?
  6. What is types.MappingProxyType and where does Python use it internally?
  7. Why is a mutable default argument a bug? What is the correct pattern?
  8. What is a value object in DDD and why must it be immutable?

Key Takeaways

  • Python's mutable types (list, dict, set) allow in-place modification; immutable types (int, str, tuple, frozenset, bytes) do not. Shared references to mutable objects create action at a distance - changes through one name affect all other names bound to the same object.
  • frozen=True on a dataclass is shallow immutability: it prevents rebinding of attributes (raises FrozenInstanceError) but does not prevent mutation of mutable objects stored in those attributes. A list inside a frozen dataclass can still be appended to.
  • A tuple is hashable only if all its elements are hashable. A tuple containing a list is not hashable. For deep immutability, use tuple of immutable elements and frozenset instead of set.
  • frozenset is the hashable, immutable counterpart to set. Use it as a dict key whenever you need a set-valued key - for example, caching results keyed by a combination of feature flags.
  • typing.NamedTuple is preferred over collections.namedtuple. It supports type annotations, IDE completion, and mypy. It is a tuple subclass - all tuple guarantees (hashability of hashable-element tuples, immutability at the reference level) apply.
  • dataclasses.replace(obj, field=new_value) returns a new dataclass instance with specified fields changed. The original is untouched. This is the functional update pattern - the Python equivalent of Redux-style state management.
  • types.MappingProxyType provides a read-only view of a dict. It is used by Python itself to expose class __dict__. Use it to expose configuration or settings dicts to consumers that should read but not write.
  • Never use mutable default arguments (def f(x, lst=[])) - the default object is created once and shared across all calls. Use None as the sentinel and create a new object inside the function body.
  • Value objects in DDD are defined entirely by their attributes, have no identity, and must be immutable. Frozen dataclasses with all-immutable fields are the idiomatic Python implementation.

Graded Practice

Level 1 - Predict the Output

Question 1

t = (1, 2, [3, 4])
t[2].append(5)
print(t)
print(len(t))
Show Answer
(1, 2, [3, 4, 5])
3

t[2] is a reference to the list [3, 4]. The tuple t is immutable at the reference level - you cannot do t[2] = something_else. But the list object that t[2] refers to is mutable, and t[2].append(5) mutates it. The tuple now "contains" [3, 4, 5], even though the tuple itself was never "modified" - the reference is unchanged, but the object it points to is different. The tuple's length is still 3 because no references were added or removed.

Question 2

from dataclasses import dataclass

@dataclass(frozen=True)
class Tag:
name: str
value: str

t1 = Tag(name="env", value="prod")
t2 = Tag(name="env", value="prod")
t3 = Tag(name="env", value="dev")

print(t1 == t2)
print(t1 is t2)
print(hash(t1) == hash(t2))
print({t1, t2, t3})
Show Answer
True
False
True
{Tag(name='env', value='prod'), Tag(name='env', value='dev')}

frozen=True generates __eq__ (compares field values) and __hash__ (based on field values). t1 == t2 is True because both have the same name and value. t1 is t2 is False because they are separate objects in memory. hash(t1) == hash(t2) is True because equal objects must have equal hashes (required by Python's hash contract). The set {t1, t2, t3} contains two elements because t1 and t2 are considered equal and t3 is different.

Question 3

from typing import NamedTuple

class Point(NamedTuple):
x: float
y: float

p = Point(1.0, 2.0)
p2 = p._replace(x=10.0)

print(p)
print(p2)
print(p == (1.0, 2.0))
print(isinstance(p, tuple))
Show Answer
Point(x=1.0, y=2.0)
Point(x=10.0, y=2.0)
True
True

_replace returns a new Point with x=10.0; the original p is untouched. p == (1.0, 2.0) is True because NamedTuple subclasses tuple and tuple equality compares element-by-element. isinstance(p, tuple) is True for the same reason - a NamedTuple IS a tuple. This means named tuples can be used anywhere a tuple is expected, including as dict keys (if all elements are hashable).

Question 4

from types import MappingProxyType

d = {"a": 1, "b": 2}
proxy = MappingProxyType(d)

d["c"] = 3
print(proxy.get("c"))

try:
proxy["d"] = 4
except TypeError as e:
print(f"Error: {e}")

print(len(proxy))
Show Answer
3
Error: 'mappingproxy' object does not support item assignment
3

proxy is a read-only view of d, not a copy. When d["c"] = 3 is added to the underlying dict, proxy.get("c") returns 3 - the proxy reflects the live state of d. Writing through the proxy (proxy["d"] = 4) raises TypeError. len(proxy) is 3 because the proxy now sees d's three keys: "a", "b", and "c".

Question 5

def make_point(x, y, metadata={}):
metadata["created"] = True
return (x, y, metadata)

p1 = make_point(1, 2)
p2 = make_point(3, 4)
print(p1)
print(p2)
print(p1[2] is p2[2])
Show Answer
(1, 2, {'created': True})
(3, 4, {'created': True})
True

metadata={} is a mutable default argument. The dict {} is created once, at function definition time, and reused across all calls. Both p1[2] and p2[2] reference the exact same dict object (p1[2] is p2[2] is True). The dict already has {'created': True} from the first call when the second call runs - but since the key is set to the same value again, the output looks identical. The bug becomes obvious if you do something like metadata[x] = y - data from all previous calls accumulates in the same dict.

Level 2 - Debug Challenge

The following class is supposed to represent an immutable shopping cart where adding an item returns a new cart. Find why it is not actually immutable and fix it.

from dataclasses import dataclass

@dataclass(frozen=True)
class Cart:
user_id: int
items: list[str] # BUG

def add_item(self, item: str) -> "Cart":
self.items.append(item) # BUG
return self

cart1 = Cart(user_id=1, items=[])
cart2 = cart1.add_item("book")
cart3 = cart2.add_item("pen")

print(cart1.items)
print(cart2.items)
print(cart3 is cart2)
Show Answer

There are two bugs:

Bug 1 - Mutable field: items: list[str] stores a mutable list. Even with frozen=True, the list can be mutated through self.items.append(...). frozen=True only prevents self.items = new_list (rebinding), not self.items.append(...) (mutating the list object).

Bug 2 - Returns self: add_item returns self after mutating it. This means cart1, cart2, and cart3 all refer to the same object - there is no new cart, and all "carts" share the same mutated state.

Current output (buggy):

['book', 'pen'] # cart1 was mutated - supposed to be []
['book', 'pen'] # cart2 was mutated - supposed to be ['book']
True # cart3 is cart2 - supposed to be a new object

Fixed version:

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Cart:
user_id: int
items: tuple[str, ...] # FIX 1: use tuple instead of list

def add_item(self, item: str) -> "Cart":
return replace(self, items=self.items + (item,)) # FIX 2: return new Cart

cart1 = Cart(user_id=1, items=())
cart2 = cart1.add_item("book")
cart3 = cart2.add_item("pen")

print(cart1.items) # () - original unchanged
print(cart2.items) # ('book',) - new cart
print(cart3.items) # ('book', 'pen') - new cart
print(cart3 is cart2) # False - distinct objects

The combination of tuple fields and dataclasses.replace() achieves true shallow immutability. Each add_item creates a new Cart with a new tuple; the original Cart is never modified.

Level 3 - Design Challenge

Design an immutable event sourcing system for a bank account in Python. Requirements:

  1. An Account is a value object: immutable, identified by its state, not by a mutable object reference
  2. deposit(amount) and withdraw(amount) return new Account objects
  3. Every state transition produces an event (a record of what happened)
  4. The full history of events can be replayed to reconstruct any past account state
  5. The account balance can never be negative (enforce this in the pure core)

All data structures must be genuinely immutable (no list, no dict as fields in immutable objects).

Show Answer
from dataclasses import dataclass, replace
from decimal import Decimal
from typing import Literal

# ============================================================
# IMMUTABLE VALUE OBJECTS
# ============================================================

@dataclass(frozen=True)
class Money:
"""Immutable value object representing an amount in a currency."""
amount: Decimal
currency: str

def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Currency mismatch: {self.currency} vs {other.currency}")
return Money(self.amount + other.amount, self.currency)

def __sub__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"Currency mismatch: {self.currency} vs {other.currency}")
return Money(self.amount - other.amount, self.currency)

def is_negative(self) -> bool:
return self.amount < Decimal("0")

def __str__(self) -> str:
return f"{self.currency} {self.amount:.2f}"


EventType = Literal["account_opened", "deposited", "withdrawn"]

@dataclass(frozen=True)
class AccountEvent:
"""Immutable record of a single state transition."""
event_type: EventType
amount: Money
balance_after: Money


@dataclass(frozen=True)
class Account:
"""
Immutable value object representing an account state.
History is stored as a tuple of AccountEvents - immutable sequence.
"""
account_id: str
owner: str
balance: Money
history: tuple[AccountEvent, ...]

@classmethod
def open(cls, account_id: str, owner: str, currency: str = "GBP") -> "Account":
"""Pure factory - creates a new account with zero balance."""
zero = Money(Decimal("0"), currency)
event = AccountEvent(
event_type="account_opened",
amount=zero,
balance_after=zero,
)
return cls(
account_id=account_id,
owner=owner,
balance=zero,
history=(event,),
)

def deposit(self, amount: Money) -> "Account":
"""Pure: returns a new Account with increased balance."""
if amount.is_negative():
raise ValueError(f"Deposit amount must be positive: {amount}")
new_balance = self.balance + amount
event = AccountEvent(
event_type="deposited",
amount=amount,
balance_after=new_balance,
)
return replace(
self,
balance=new_balance,
history=self.history + (event,),
)

def withdraw(self, amount: Money) -> "Account":
"""Pure: returns a new Account with decreased balance; raises if insufficient."""
if amount.is_negative():
raise ValueError(f"Withdrawal amount must be positive: {amount}")
new_balance = self.balance - amount
if new_balance.is_negative():
raise ValueError(
f"Insufficient funds: balance {self.balance}, requested {amount}"
)
event = AccountEvent(
event_type="withdrawn",
amount=amount,
balance_after=new_balance,
)
return replace(
self,
balance=new_balance,
history=self.history + (event,),
)


# ============================================================
# PURE REPLAY FUNCTION
# ============================================================

def replay_to_event(events: tuple[AccountEvent, ...], n: int) -> Money:
"""Pure: replays the first n events and returns the balance at that point."""
if n <= 0 or not events:
return events[0].balance_after if events else Money(Decimal("0"), "GBP")
return events[min(n, len(events)) - 1].balance_after


# ============================================================
# DEMO - everything is pure, immutable, and reproducible
# ============================================================

gbp = lambda amount: Money(Decimal(str(amount)), "GBP")

# Open account - pure factory
acc0 = Account.open("ACC-001", "Alice")
print(f"Opened: {acc0.balance}") # GBP 0.00

# Deposit - returns new account, original untouched
acc1 = acc0.deposit(gbp(500))
print(f"After deposit: {acc1.balance}") # GBP 500.00
print(f"Original: {acc0.balance}") # GBP 0.00 - unchanged

# Withdraw
acc2 = acc1.withdraw(gbp(120))
print(f"After withdrawal: {acc2.balance}") # GBP 380.00

# Another deposit
acc3 = acc2.deposit(gbp(50))
print(f"Final balance: {acc3.balance}") # GBP 430.00

# Full history
print(f"\nEvent history ({len(acc3.history)} events):")
for i, event in enumerate(acc3.history):
print(f" [{i}] {event.event_type}: {event.amount} → balance {event.balance_after}")

# Replay: what was the balance after the first 2 events?
balance_at_2 = replay_to_event(acc3.history, 2)
print(f"\nBalance after event 2: {balance_at_2}") # GBP 500.00

# Overdraft protection (pure core enforces the invariant)
try:
acc3.withdraw(gbp(9999))
except ValueError as e:
print(f"\nOverdraft prevented: {e}")

# Account states are hashable - can be used as dict keys, stored in sets
states = {acc0, acc1, acc2, acc3}
print(f"\nDistinct account states: {len(states)}") # 4

Output:

Opened: GBP 0.00
After deposit: GBP 500.00
Original: GBP 0.00 - unchanged

After withdrawal: GBP 380.00
Final balance: GBP 430.00

Event history (4 events):
[0] account_opened: GBP 0.00 → balance GBP 0.00
[1] deposited: GBP 500.00 → balance GBP 500.00
[2] withdrawn: GBP 120.00 → balance GBP 380.00
[3] deposited: GBP 50.00 → balance GBP 430.00

Balance after event 2: GBP 500.00

Overdraft prevented: Insufficient funds: balance GBP 430.00, requested GBP 9999.00

Distinct account states: 4

Design decisions:

  • Money and AccountEvent and Account are all frozen=True dataclasses with all-immutable fields (str, Decimal, tuple)
  • No list or dict appears as a field in any immutable class
  • deposit and withdraw return new Account objects via replace() - the original is always preserved
  • Business invariants (no negative deposits, no overdraft) are enforced in the pure core
  • The replay_to_event function is pure - it reconstructs historical state from an immutable event log without any I/O
  • Every Account state is hashable and can be stored in a set or used as a dict key

What's Next

Lesson 09 covers the functools module in full - lru_cache, cache, wraps, partial, reduce, total_ordering, cached_property, and singledispatch. You now understand why lru_cache requires pure functions (from this lesson and Lesson 07). The next lesson shows you how to use every tool in functools at engineering depth, including the subtle memory leak caused by applying lru_cache to instance methods.

© 2026 EngineersOfAI. All rights reserved.