Skip to main content

Python Immutability Strategies Practice Problems & Exercises

Practice: Immutability Strategies

11 problems4 Easy4 Medium3 Hard45-60 min
← Back to lesson

Easy

#1Named Tuple vs Regular TupleEasy
namedtupletupleimmutabilityfield-access

Create a Point named tuple. Demonstrate field access by name, verify it is a tuple subclass, and confirm immutability.

Python
from collections import namedtuple

# Regular tuple
plain_point = (3, 4)
print(f"point tuple: {plain_point}")

# Named tuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)

print(f"named point x: {p.x}")
print(f"named point y: {p.y}")
print(f"is tuple: {isinstance(p, tuple)}")

# Verify immutability
try:
    p.x = 10
    print("immutable: False")
except AttributeError:
    print("immutable: True")

print(f"fields: {p._fields}")
Solution
point tuple: (3, 4)
named point x: 3
named point y: 4
is tuple: True
immutable: True
fields: ('x', 'y')

Named tuple internals:

namedtuple("Point", ["x", "y"]) generates a new class that inherits from tuple. The class adds:

  • Named attribute access (p.x, p.y) via properties.
  • A _fields tuple listing all field names.
  • _asdict() to convert to an OrderedDict.
  • _replace(**kwargs) to create a new instance with some fields changed.

Because named tuples inherit from tuple, all tuple immutability properties apply. p.x = 10 raises AttributeError: can't set attribute. The underlying tuple storage is fixed at construction.

When to use named tuples over plain dicts: When your data has a fixed schema, field names make code more readable, and you want to prevent mutation. Named tuples are also more memory-efficient than dicts for fixed-schema records.

Expected Output
point tuple: (3, 4)
named point x: 3
named point y: 4
is tuple: True
immutable: True
fields: ('x', 'y')
Hints

Hint 1: Use `collections.namedtuple` to create a named tuple class. Fields are accessed by name (`.x`, `.y`) in addition to index (`[0]`, `[1]`).

Hint 2: Named tuples are still tuples — `isinstance(p, tuple)` returns `True`. Attempting to assign `p.x = 10` raises `AttributeError`.

#2Frozen DataclassEasy
dataclassfrozenimmutabilityhash

Create a frozen DatabaseConfig dataclass. Verify immutability and that the instance is hashable (usable as a dict key).

Python
from dataclasses import dataclass

@dataclass(frozen=True)
class DatabaseConfig:
    host: str
    port: int
    database: str = "main"

config = DatabaseConfig(host="localhost", port=5432)
print(f"config host: {config.host}")
print(f"config port: {config.port}")

# Verify immutability
try:
    config.host = "remotehost"
    print("immutable: False")
except Exception:
    print("immutable: True")

# Verify hashability (frozen dataclasses generate __hash__)
try:
    d = {config: "active"}
    print("hashable: True")
except TypeError:
    print("hashable: False")

# Hash is consistent across calls
print(f"hash value is consistent: {hash(config) == hash(config)}")
Solution
config host: localhost
config port: 5432
immutable: True
hashable: True
hash value is consistent: True

frozen=True mechanics:

@dataclass(frozen=True) generates __setattr__ and __delattr__ methods that raise FrozenInstanceError (a subclass of AttributeError) on any attempt to modify or delete a field after construction.

It also generates __hash__ based on all fields. A regular (non-frozen) @dataclass sets __hash__ = None by default (making instances unhashable) because mutable objects should not be hashed — if an object in a set is mutated, its hash would change and the set's internal bucket would be wrong.

Frozen dataclass vs namedtuple:

  • Frozen dataclass: type annotations, default values, __post_init__, inheritable, reads like a normal class.
  • Named tuple: lighter weight, tuple semantics (indexable, iterable), slightly faster for read-heavy code.

Use frozen dataclasses for domain model value objects where you want type annotations and optional defaults. Use named tuples for lightweight record types.

Expected Output
config host: localhost
config port: 5432
immutable: True
hashable: True
hash value is consistent: True
Hints

Hint 1: Use `@dataclass(frozen=True)` to create an immutable dataclass. Attempting to assign any field raises `FrozenInstanceError`.

Hint 2: Frozen dataclasses are automatically hashable, so they can be used as dict keys or in sets.

#3Tuple as Immutable RecordEasy
tupleimmutabilityrecordunpacking

Use a plain tuple as an immutable employee record. Demonstrate unpacking, show that mutation is impossible, and create a "salary raise" by building a new tuple.

Python
# Create employee record as a tuple
employee = (101, "Alice", 75000)

# Field access by index
print(f"employee id: {employee[0]}")
print(f"employee name: {employee[1]}")
print(f"employee salary: {employee[2]}")

# Unpacking
emp_id, name, salary = employee
print(f"unpacked: {emp_id} {name} {salary}")

# Verify immutability
try:
    employee[2] = 80000
    print("is immutable: False")
except TypeError:
    print("is immutable: True")

# "Updating" salary — build a new tuple
def give_raise(emp, raise_amount):
    emp_id, name, salary = emp
    return (emp_id, name, salary + raise_amount)

raised = give_raise(employee, 7500)
print(f"new record with raise: {raised}")
Solution
employee id: 101
employee name: Alice
employee salary: 75000
unpacked: 101 Alice 75000
is immutable: True
new record with raise: (101, 'Alice', 82500)

Tuples as value objects:

A Python tuple is immutable at the reference level — you cannot reassign employee[2]. If a tuple contains mutable objects (like a list), the list itself can be mutated (the tuple holds a reference to the list, and that reference is immutable, but the list's contents are not). For purely immutable records, all fields should themselves be immutable types (int, str, float, another tuple).

"Copy with modification" via tuple construction: give_raise unpacks the original tuple, computes the new salary, and constructs a fresh tuple. The original employee is unchanged. This is the copy-on-write pattern applied to tuples — to "change" data, you create a new version.

This pattern is the basis of persistent data structures used in functional languages and is what Python's standard immutable types (str, int, frozenset) do internally.

Expected Output
employee id: 101
employee name: Alice
employee salary: 75000
unpacked: 101 Alice 75000
is immutable: True
new record with raise: (101, 'Alice', 82500)
Hints

Hint 1: A plain tuple can serve as a simple immutable record when named tuple overhead is not needed. Combine with unpacking for readable access.

Hint 2: To "update" a field, build a new tuple rather than modifying the existing one. Use slicing or explicit construction.

#4frozenset for Immutable CollectionsEasy
frozensetsetimmutabilityhashable

Use frozenset to represent an immutable set of allowed HTTP methods. Verify that mutation is blocked and that frozenset is hashable.

Python
allowed_methods = frozenset({"GET", "POST", "PUT", "DELETE"})
print(f"allowed_methods: {allowed_methods}")

print(f"GET allowed: {'GET' in allowed_methods}")
print(f"PATCH allowed: {'PATCH' in allowed_methods}")

# Verify hashability
try:
    d = {allowed_methods: "v1_routes"}
    print("is hashable: True")
except TypeError:
    print("is hashable: False")

# Mutation attempt
try:
    allowed_methods.add("PATCH")
    print("mutation succeeded: True")
except AttributeError:
    pass

# Set operations still work (return new frozensets)
requested = frozenset({"GET", "POST", "PATCH"})
overlap = allowed_methods & requested
print(f"intersection with requested: {overlap}")
Solution
allowed_methods: frozenset({'DELETE', 'GET', 'POST', 'PUT'})
GET allowed: True
PATCH allowed: False
is hashable: True
intersection with requested: frozenset({'GET', 'POST'})

frozenset vs set:

Operationsetfrozenset
in membershipYesYes
&, `, -, ^`Yes (returns new set)
.add(), .remove(), .discard()YesAttributeError
HashableNoYes
Can be a dict keyNoYes
Can be in a setNoYes

Use cases for frozenset:

  • Configuration sets that should not be mutated (HTTP methods, valid states, feature flags).
  • Storing sets inside dicts or other sets (requires hashability).
  • Function arguments that represent a fixed group of values — a frozenset parameter signals to readers that the set will not be modified.

Set operations (&, |, -) on frozenset always return a new frozenset, never modify in-place. This maintains immutability throughout set arithmetic.

Expected Output
allowed_methods: frozenset({'DELETE', 'GET', 'POST', 'PUT'})
GET allowed: True
PATCH allowed: False
is hashable: True
intersection with requested: frozenset({'GET', 'POST'})
Hints

Hint 1: Create a `frozenset` from an iterable. It supports all read-only set operations (intersection, union, difference, `in`) but raises `AttributeError` on mutation methods like `.add()`.

Hint 2: Because `frozenset` is hashable, it can be used as a dict key or stored inside another set — unlike a regular `set`.


Medium

#5Namedtuple _replace for Copy-on-WriteMedium
namedtuple_replacecopy-on-writeversion-history

Use namedtuple._replace to implement copy-on-write version history for a configuration object. Keep all versions and verify earlier ones are unmodified.

Python
from collections import namedtuple

Config = namedtuple("Config", ["host", "port", "debug"])

# Version 1: initial config
v1 = Config(host="localhost", port=5432, debug=False)

# Version 2: enable debug (copy-on-write)
v2 = v1._replace(debug=True)

# Version 3: switch to production host, disable debug
v3 = v2._replace(host="prod.db.internal", debug=False)

print(f"v1: {v1}")
print(f"v2: {v2}")
print(f"v3: {v3}")

# Verify originals are unchanged
print(f"v1 unchanged: {v1.debug is False and v1.host == 'localhost'}")
print(f"v2 unchanged after v3 created: {v2.debug is True and v2.host == 'localhost'}")
print(f"all different objects: {v1 is not v2 is not v3}")
Solution
v1: Config(host='localhost', port=5432, debug=False)
v2: Config(host='localhost', port=5432, debug=True)
v3: Config(host='prod.db.internal', port=5432, debug=False)
v1 unchanged: True
v2 unchanged after v3 created: True
all different objects: True

_replace mechanics:

_replace(**kwargs) creates a shallow copy of the named tuple with the specified fields overridden. Fields not mentioned in kwargs are copied from the original. Because tuples are immutable, _replace must build a new tuple — it cannot modify the existing one.

Copy-on-write version history:

  • v1 holds the original values.
  • v2 = v1._replace(debug=True) creates a new named tuple. v1 is not touched — v1.debug is still False.
  • v3 = v2._replace(host="prod.db.internal", debug=False) creates another new named tuple. v2 is not touched.

This pattern provides a free audit trail: [v1, v2, v3] is a complete history of configuration changes. Reverting to any previous config is trivial — just use the earlier version.

Comparison with dataclass._replace (dataclasses.replace): Frozen dataclasses use dataclasses.replace(obj, field=value) for the same pattern. Named tuples use ._replace(). Both produce a new instance with the original unchanged.

Expected Output
v1: Config(host='localhost', port=5432, debug=False)
v2: Config(host='localhost', port=5432, debug=True)
v3: Config(host='prod.db.internal', port=5432, debug=False)
v1 unchanged: True
v2 unchanged after v3 created: True
all different objects: True
Hints

Hint 1: Use `config._replace(field=new_value)` to create a modified copy. The original is unchanged.

Hint 2: Collect each version in a list. Verify that earlier versions still hold their original field values even after later versions are created.

#6Defensive Copying to Prevent MutationMedium
defensive-copydeep-copyshallow-copymutation-prevention

Demonstrate the difference between shallow and deep copy as defensive copying strategies. Show when each is sufficient.

Python
import copy

nested = [[1, 2], [3, 4]]

# Shallow copy
shallow = list(nested)
shallow.append([5, 6])        # adding to outer — does NOT affect original
shallow[0].append(99)         # mutating inner — DOES affect original

print(f"shallow: outer list changed: {len(nested) == 3}, inner list changed: {nested[0] == [1, 2, 99]}")

# Restore original for deep copy test
nested = [[1, 2], [3, 4]]

# Deep copy
deep = copy.deepcopy(nested)
deep.append([5, 6])           # adding to outer — does NOT affect original
deep[0].append(99)            # mutating inner — does NOT affect original

print(f"deep: outer list changed: {len(nested) == 3}, inner list changed: {nested[0] == [1, 2, 99]}")

# Defensive storage pattern
class SafeConfig:
    def __init__(self, allowed_ips):
        self._allowed_ips = copy.deepcopy(allowed_ips)

    def allowed(self):
        return copy.deepcopy(self._allowed_ips)

external = [[1, 2], [3, 4]]
config = SafeConfig(external)
external[0].append(999)  # caller mutates their list after storing
print(f"original after defensive store: {config.allowed()}")
Solution
shallow: outer list changed: False, inner list changed: True
deep: outer list changed: False, inner list changed: False
original after defensive store: [[1, 2], [3, 4]]

Shallow vs deep copy — the rule of thumb:

ScenarioUse
Flat structure (no nested mutables)Shallow copy — list(x), dict(x), x.copy()
Nested mutable structureDeep copy — copy.deepcopy(x)
Performance-critical, known flat dataShallow — it is O(n) not O(total nodes)

Defensive copying in SafeConfig:

The constructor calls copy.deepcopy(allowed_ips) on the incoming data. Even if the caller mutates their external list after passing it to SafeConfig, the stored _allowed_ips is unaffected. The allowed() method also returns a deep copy — preventing callers from mutating the internal state via the returned reference.

The two defensive copy points:

  1. On store (constructor or setter): copy incoming data so the caller's mutations don't affect you.
  2. On return (getter): copy outgoing data so the caller's mutations don't affect your internal state.

Both are needed for full immutability. Omitting either creates a window for accidental mutation.

Expected Output
shallow: outer list changed: False, inner list changed: True
deep: outer list changed: False, inner list changed: False
original after defensive store: [[1, 2], [3, 4]]
Hints

Hint 1: A shallow copy (via `list(...)` or `.copy()`) creates a new outer container but shares references to nested objects. Mutating a nested list still affects the original.

Hint 2: A deep copy (`copy.deepcopy`) recursively copies all nested objects. Mutating the deep copy does not affect the original at any level.

#7Immutable Record with ValidationMedium
frozen-dataclass__post_init__validationvalue-object

Create a frozen Order dataclass with __post_init__ validation. Ensure amount is positive, currency is non-empty, and status is one of the allowed values.

Python
from dataclasses import dataclass

VALID_STATUSES = {"pending", "processing", "completed", "cancelled"}

@dataclass(frozen=True)
class Order:
    order_id: str
    amount: float
    currency: str
    status: str = "pending"

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("amount must be positive")
        if not self.currency.strip():
            raise ValueError("currency must be non-empty")
        if self.status not in VALID_STATUSES:
            raise ValueError(f"status must be one of {VALID_STATUSES}")

# Valid order
order = Order(order_id="ORD-001", amount=99.99, currency="USD")
print(f"valid order: {order}")

# Validation failures
for bad_kwargs, expected_msg in [
    ({"order_id": "X", "amount": -1.0, "currency": "USD"}, "amount must be positive"),
    ({"order_id": "X", "amount": 10.0, "currency": ""}, "currency must be non-empty"),
    ({"order_id": "X", "amount": 10.0, "currency": "USD", "status": "shipped"}, "status must be one of"),
]:
    try:
        Order(**bad_kwargs)
        print("no error raised")
    except ValueError as e:
        print(f"{expected_msg}: {str(e)[:len(expected_msg)]}")
Solution
valid order: Order(order_id='ORD-001', amount=99.99, currency='USD', status='pending')
negative amount error: amount must be positive
empty currency error: currency must be non-empty
invalid status error: status must be one of

__post_init__ with frozen dataclasses:

__post_init__ is called by the generated __init__ after all fields are assigned. At this point the fields are readable (self.amount, self.currency), so you can validate them. However, you cannot assign to them (self.amount = x raises FrozenInstanceError) because freezing happens at the __setattr__ level, not just after __post_init__.

Raising ValueError from __post_init__ causes Order(...) to fail with that error, so invalid instances cannot be created. This is the constructor validation pattern — an object either comes into existence fully valid, or not at all. There is no window where an invalid Order object exists.

Value object pattern: An Order with specific fields that define its identity is a value object from Domain-Driven Design. Two Order instances with identical field values are considered equal. frozen=True generates __eq__ based on all fields, making this automatic.

Expected Output
valid order: Order(order_id='ORD-001', amount=99.99, currency='USD', status='pending')
negative amount error: amount must be positive
empty currency error: currency must be non-empty
invalid status error: status must be one of
Hints

Hint 1: Use `@dataclass(frozen=True)` with a `__post_init__` method to validate fields after construction. Raise `ValueError` for invalid inputs.

Hint 2: `__post_init__` runs after `__init__` but before the instance is frozen. You can read field values but not assign to them (that would raise FrozenInstanceError).

#8MappingProxyType for Read-Only DictsMedium
MappingProxyTyperead-onlydicttypes

Use types.MappingProxyType to expose a read-only view of a configuration dict. Show that writes are blocked but reads and live updates from the underlying dict work.

Python
from types import MappingProxyType

_internal_config = {"version": "v1", "debug": False, "max_retries": 3}
config = MappingProxyType(_internal_config)

# Reading works
print(f"read value: {config['version']}")
print(f"read works: {'version' in config}")

# Writing is blocked
try:
    config["version"] = "v2"
    print("write blocked: False")
except TypeError:
    print("write blocked: True")

# Deleting is blocked
try:
    del config["version"]
    print("delete blocked: False")
except TypeError:
    print("delete blocked: True")

# Live view — underlying dict update IS visible through proxy
_internal_config["version"] = "v2"
print(f"underlying dict updated: {_internal_config['version'] == 'v2'}")
print(f"proxy sees update: {config['version'] == 'v2'}")
Solution
read value: v1
read works: True
write blocked: True
delete blocked: True
underlying dict updated: True
proxy sees update: True

MappingProxyType — a read-only view, not a copy:

MappingProxyType wraps the original dict without copying it. config and _internal_config share the same underlying data. This has two consequences:

  1. Consumers cannot mutate via the proxyconfig["key"] = value raises TypeError: 'mappingproxy' object does not support item assignment.

  2. The module or class that owns the internal dict CAN still update it — and the proxy reflects those changes. This is intentional: it is a pattern for controlled mutation where only authorised code (holding a reference to _internal_config) can change the data, while external consumers see only a read-only view.

Common use cases:

  • __dict__ on class objects — Python internally uses MappingProxyType to expose class __dict__ as read-only.
  • Module-level configuration exposed to callers who should not be able to change it.
  • Public API for caches or registries maintained internally.
Expected Output
read value: v1
read works: True
write blocked: True
delete blocked: True
underlying dict updated: True
proxy sees update: True
Hints

Hint 1: `types.MappingProxyType(d)` wraps a dict in a read-only view. Attempting `proxy["k"] = v` raises `TypeError`.

Hint 2: The proxy is a live view — if the underlying dict is updated (by someone with a direct reference to it), the proxy reflects the change.


Hard

#9Persistent Linked List (Structural Sharing)Hard
persistent-datastructural-sharingnamedtupleprepend

Implement a persistent singly linked list using named tuples. Demonstrate structural sharing: two lists can share a common tail with no copying.

Python
from collections import namedtuple

Node = namedtuple("Node", ["value", "tail"])

def prepend(value, lst):
    return Node(value, lst)

def to_python_list(lst):
    result = []
    current = lst
    while current is not None:
        result.append(current.value)
        current = current.tail
    return result

# Build list1: [3, 2, 1]
list1 = prepend(1, None)
list1 = prepend(2, list1)
list1 = prepend(3, list1)

# list2: prepend 4 to list1 — shares list1 as tail
list2 = prepend(4, list1)

# list3: prepend 5 to list1 — also shares list1 as tail
list3 = prepend(5, list1)

print(f"list1: {to_python_list(list1)}")
print(f"list2 (prepend 4): {to_python_list(list2)}")
print(f"list3 (prepend 5 to list1): {to_python_list(list3)}")
print(f"list1 unchanged: {to_python_list(list1)}")

# Structural sharing: list2's tail IS list1 (same object, not a copy)
print(f"list2 tail is list1: {list2.tail is list1}")
Solution
list1: [3, 2, 1]
list2 (prepend 4): [4, 3, 2, 1]
list3 (prepend 5 to list1): [5, 3, 2, 1]
list1 unchanged: [3, 2, 1]
list2 tail is list1: True

Structural sharing — the key insight:

When list2 = prepend(4, list1) is called, Python creates one new Node(4, list1). The new node's tail is the existing list1 object — no copying, no allocation of the three existing nodes. Memory usage for list2 is O(1), not O(n).

Because Node is a named tuple (immutable), list1 cannot be modified through list2. When list3 = prepend(5, list1) is created, both list2 and list3 share the same tail list1. Modification of any derived list is impossible — structural sharing is safe only because of immutability.

Memory layout:

list3 -> Node(5) -> Node(3) -> Node(2) -> Node(1) -> None
^
list2 -> Node(4) ----+

Both list2 and list3 share the three-node chain of list1. This is the exact structure used in functional languages (Haskell, Erlang, Clojure) for persistent sequences, and in Git's object model for trees.

Expected Output
list1: [3, 2, 1]
list2 (prepend 4): [4, 3, 2, 1]
list3 (prepend 5 to list1): [5, 3, 2, 1]
list1 unchanged: [3, 2, 1]
list2 tail is list1: True
Hints

Hint 1: Model a singly linked list as a namedtuple `Node(value, tail)` where `tail` is either another `Node` or `None`.

Hint 2: Prepending is O(1) and non-destructive: `Node(new_value, existing_list)`. The new node points to the existing list — structural sharing means no copying.

#10Immutable Config Builder with Method ChainingHard
builder-patternfrozen-dataclassdataclasses.replacemethod-chaining

Implement an immutable ServerConfig builder using a frozen dataclass and dataclasses.replace. Each builder method returns a new instance; chaining produces a fully configured object.

Python
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class ServerConfig:
    host: str = "localhost"
    port: int = 8080
    workers: int = 4
    timeout: int = 30
    debug: bool = False

    def with_host(self, host):
        return replace(self, host=host)

    def with_port(self, port):
        return replace(self, port=port)

    def with_workers(self, workers):
        return replace(self, workers=workers)

    def with_timeout(self, timeout):
        return replace(self, timeout=timeout)

    def with_debug(self, debug=True):
        return replace(self, debug=debug)

# Usage
base = ServerConfig()
print(f"base: {base}")

with_debug = base.with_debug()
print(f"with debug: {with_debug}")

production = (base
    .with_host("api.prod.com")
    .with_port(443)
    .with_workers(16)
    .with_timeout(60))
print(f"production: {production}")

print(f"base unchanged: {base == ServerConfig()}")

# Chained from scratch
chained = (ServerConfig()
    .with_host("api.prod.com")
    .with_port(443)
    .with_workers(16)
    .with_timeout(60))
print(f"chained: {chained}")
Solution
base: ServerConfig(host='localhost', port=8080, workers=4, timeout=30, debug=False)
with debug: ServerConfig(host='localhost', port=8080, workers=4, timeout=30, debug=True)
production: ServerConfig(host='api.prod.com', port=443, workers=16, timeout=60, debug=False)
base unchanged: True
chained: ServerConfig(host='api.prod.com', port=443, workers=16, timeout=60, debug=False)

Immutable builder pattern:

The classic (mutable) builder pattern accumulates mutations in a builder object and calls .build() at the end. The immutable version has no separate builder class — each with_* method on the config itself returns a new config with one field changed.

dataclasses.replace(self, field=new_value) creates a shallow copy of the frozen dataclass with the specified field overridden. self is not modified. The new object goes through __post_init__ (if defined), allowing validation of the updated fields.

Chaining: Each with_* call returns a new ServerConfig instance, so you can chain immediately. The intermediate objects are temporary — they will be garbage collected unless stored. The final object in the chain is the fully configured instance.

Advantages over mutable builders:

  • Thread-safe: multiple threads can derive different configurations from the same base without locking.
  • Audit trail: store intermediate configs as named variables for debugging.
  • Immutable by construction: the resulting config cannot be modified after creation.
Expected Output
base: ServerConfig(host='localhost', port=8080, workers=4, timeout=30, debug=False)
with debug: ServerConfig(host='localhost', port=8080, workers=4, timeout=30, debug=True)
production: ServerConfig(host='api.prod.com', port=443, workers=16, timeout=60, debug=False)
base unchanged: True
chained: ServerConfig(host='api.prod.com', port=443, workers=16, timeout=60, debug=False)
Hints

Hint 1: Use `dataclasses.replace(self, **kwargs)` inside each builder method to return a new frozen dataclass instance.

Hint 2: Because each method returns a new instance, you can chain calls: `config.with_host("h").with_port(443).with_workers(16)`.

#11Immutable Event Log with Time-TravelHard
event-sourcingimmutabilityaudit-logtime-travelreplay

Build an immutable event log for a bank account. The current balance is always derived by replaying events. Implement time-travel by replaying only a subset of events.

Python
from collections import namedtuple

Event = namedtuple("Event", ["kind", "amount"])

def apply_event(balance, event):
    if event.kind == "deposit":
        return balance + event.amount
    elif event.kind == "withdraw":
        return balance - event.amount
    return balance

def replay(events, up_to=None):
    target = events if up_to is None else events[:up_to]
    balance = 0
    for event in target:
        balance = apply_event(balance, event)
    return balance

class ImmutableLedger:
    def __init__(self):
        self._events = ()

    def deposit(self, amount):
        new_ledger = ImmutableLedger.__new__(ImmutableLedger)
        new_ledger._events = self._events + (Event("deposit", amount),)
        return new_ledger

    def withdraw(self, amount):
        new_ledger = ImmutableLedger.__new__(ImmutableLedger)
        new_ledger._events = self._events + (Event("withdraw", amount),)
        return new_ledger

    @property
    def balance(self):
        return replay(self._events)

    def balance_at(self, event_index):
        return replay(self._events, up_to=event_index)

    @property
    def event_count(self):
        return len(self._events)

# Usage
ledger = ImmutableLedger()
ledger = ledger.deposit(1000)
ledger = ledger.withdraw(250)
ledger = ledger.withdraw(200)

print(f"current balance: {ledger.balance}")
print(f"event count: {ledger.event_count}")

for i in range(ledger.event_count + 1):
    print(f"balance at event {i}: {ledger.balance_at(i)}")

print(f"replay matches current: {ledger.balance_at(ledger.event_count) == ledger.balance}")
Solution
current balance: 550
event count: 4
balance at event 0: 0
balance at event 1: 1000
balance at event 2: 750
balance at event 3: 550
replay matches current: True

Event sourcing via immutable append-only log:

The ledger stores a tuple of Event named tuples. Tuples are immutable, so the history cannot be altered. Each deposit or withdraw creates a new ImmutableLedger instance with the new event appended to the old tuple — O(n) copy, but preserves full history immutably.

replay is a pure fold:

replay(events, up_to=N) takes a slice of the events tuple and applies apply_event (also pure) to each one, starting from balance 0. Because both functions are pure, calling replay with the same arguments always returns the same balance. This is the foundation of event sourcing: current state = fold(initial state, all events).

Time travel:

balance_at(i) replays only the first i events. This gives the exact balance immediately after event i-1 was applied. This is how database PITR (point-in-time recovery), Redux time-travel debugging, and Git git log all work conceptually.

Immutability guarantee: Because _events is a tuple and Event is a named tuple, no code can delete, modify, or reorder historical events. The audit trail is permanent.

Expected Output
current balance: 550
event count: 4
balance at event 0: 0
balance at event 1: 1000
balance at event 2: 750
balance at event 3: 550
replay matches current: True
Hints

Hint 1: Store each event as an immutable named tuple. The current state is derived by replaying all events from the beginning.

Hint 2: Time travel to state N means replaying only the first N events. Write a `replay(events, up_to)` pure function that folds events into an initial state.

© 2026 EngineersOfAI. All rights reserved.