Skip to main content

Python Mutable Practice Problems & Exercises

Practice: Mutable vs Immutable Revisited

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

#1Classify MutabilityEasy
mutabilitytype classification

Problem: Write a function that takes any Python object and returns 'mutable' or 'immutable' based on its type. Cover all built-in types listed in the docstring.

Solution
def classify_mutability(obj):
immutable_types = (int, float, str, bool, tuple, frozenset, bytes)
if isinstance(obj, immutable_types):
return "immutable"
return "mutable"

# Tests
assert classify_mutability(42) == "immutable"
assert classify_mutability(3.14) == "immutable"
assert classify_mutability("hello") == "immutable"
assert classify_mutability(True) == "immutable"
assert classify_mutability((1, 2)) == "immutable"
assert classify_mutability(frozenset({1})) == "immutable"
assert classify_mutability(b"data") == "immutable"
assert classify_mutability([1, 2]) == "mutable"
assert classify_mutability({"a": 1}) == "mutable"
assert classify_mutability({1, 2}) == "mutable"
assert classify_mutability(bytearray(b"x")) == "mutable"
print("All tests passed!")

Key insight: bool is a subclass of int, so checking int covers booleans too. However, listing bool explicitly makes the intent clear. Using isinstance with a tuple of types is the Pythonic way to check membership in a type set.

def classify_mutability(obj):
  """Return 'mutable' or 'immutable' based on the object's type.

  Must handle: int, float, str, bool, tuple, frozenset, bytes,
               list, dict, set, bytearray.

  Args:
      obj: Any Python object of the types listed above.

  Returns:
      'mutable' or 'immutable'
  """
  # TODO: implement this
Expected Output
classify_mutability(42) => 'immutable'
classify_mutability("hello") => 'immutable'
classify_mutability((1, 2)) => 'immutable'
classify_mutability([1, 2]) => 'mutable'
classify_mutability({}) => 'mutable'
classify_mutability(frozenset()) => 'immutable'
classify_mutability(bytearray(b"x")) => 'mutable'
Hints

Hint 1: All immutable built-in types: int, float, str, bool, tuple, frozenset, bytes.

Hint 2: Use isinstance() with a tuple of types for a clean check.


#2Identity vs Equality InspectorEasy
id()is vs ==integer caching

Problem: Write a function that takes two objects and returns a dictionary describing whether they are equal, identical, and their id values. This helps visualize when Python reuses objects vs creates new ones.

Solution
def identity_report(a, b):
return {
"equal": a == b,
"identical": a is b,
"id_a": id(a),
"id_b": id(b),
"same_object": id(a) == id(b),
}

# Tests — small integer caching
r = identity_report(100, 100)
assert r["equal"] is True
assert r["identical"] is True # cached in [-5, 256]

# Outside cache range — may or may not be identical depending on context
r2 = identity_report([1, 2], [1, 2])
assert r2["equal"] is True
assert r2["identical"] is False # different list objects

# Same object via aliasing
x = [1, 2, 3]
y = x
r3 = identity_report(x, y)
assert r3["equal"] is True
assert r3["identical"] is True

print("All tests passed!")

Key insight: == checks value equality while is checks object identity (same memory address). Small integers (-5 to 256) are cached by CPython, so 100 is 100 is True, but 1000 is 1000 may be False. Never rely on is for value comparison.

def identity_report(a, b):
  """Return a dict describing the identity/equality relationship.

  Returns:
      {
          'equal': bool,        # a == b
          'identical': bool,    # a is b
          'id_a': int,          # id(a)
          'id_b': int,          # id(b)
          'same_object': bool   # id(a) == id(b)
      }
  """
  # TODO: implement this
Expected Output
identity_report(100, 100) =>
equal=True, identical=True (cached small int)

identity_report(1000, 1000) =>
equal=True, identical=False (outside cache range)

identity_report([1,2], [1,2]) =>
equal=True, identical=False (different list objects)
Hints

Hint 1: Use == for equality and 'is' for identity.

Hint 2: id() returns the memory address in CPython. Two objects are identical iff their ids match.


#3Tuple with Mutable ElementsEasy
tuplemutable elementsshallow immutability

Problem: Demonstrate that a tuple's immutability is shallow — the tuple structure cannot change, but mutable elements inside it can be modified. Append 99 to every list inside the given tuple and return the original tuple object.

Solution
def modify_tuple_contents(t):
for lst in t:
lst.append(99)
return t

# Tests
t = ([1, 2], [3, 4])
original_id = id(t)
result = modify_tuple_contents(t)

assert result is t # Same tuple object
assert id(result) == original_id
assert result == ([1, 2, 99], [3, 4, 99])
print("All tests passed!")

Key insight: Tuple immutability means you cannot add, remove, or reassign elements at the tuple level (t[0] = new_list raises TypeError). But if an element is itself mutable (like a list), that object can be modified in place. This is why a tuple containing lists is not hashable — the hash could change if the inner lists change.

def modify_tuple_contents(t):
  """Given a tuple of lists, append 99 to each inner list.

  The tuple itself cannot be reassigned, but its mutable
  elements CAN be modified in place.

  Args:
      t: A tuple of lists, e.g. ([1, 2], [3, 4])

  Returns:
      The same tuple (same id), with each inner list
      now containing 99 at the end.
  """
  # TODO: implement this
Expected Output
t = ([1, 2], [3, 4])
result = modify_tuple_contents(t)
# result is ([1, 2, 99], [3, 4, 99])
# result is t => True  (same tuple object)
Hints

Hint 1: You cannot reassign t[0] = something_new, but you CAN call t[0].append(99).

Hint 2: Iterate over the tuple elements and mutate each list in place.


#4Fix the Mutable Default ArgumentEasy
mutable defaultNone sentinelanti-pattern

Problem: The function above has the classic mutable default argument bug. Each call without an explicit log argument shares the same list, causing events to accumulate. Fix it using the None sentinel pattern.

Solution
def collect_events(event, log=None):
if log is None:
log = []
log.append(event)
return log

# Tests — each call gets a fresh list
r1 = collect_events("login")
r2 = collect_events("click")
assert r1 == ["login"]
assert r2 == ["click"]
assert r1 is not r2 # Different list objects

# Explicit list is respected
existing = ["setup"]
r3 = collect_events("teardown", existing)
assert r3 == ["setup", "teardown"]
assert r3 is existing # Same object — intentional sharing

print("All tests passed!")

Key insight: Python evaluates default arguments once at function definition time. A mutable default like [] is created once and shared across all calls. The canonical fix: use None as the default and create a fresh container inside the function body on each call.

def collect_events(event, log=[]):
  """BUGGY: Accumulates events across calls due to shared default list.

  Fix this function so each call without an explicit log
  starts with a fresh empty list.

  Args:
      event: A string event name.
      log: Optional list to append to.

  Returns:
      The log list with the event appended.
  """
  log.append(event)
  return log
Expected Output
collect_events("login")  => ['login']
collect_events("click")  => ['click']        # NOT ['login', 'click']
collect_events("click", ["existing"]) => ['existing', 'click']
Hints

Hint 1: Replace the mutable default [] with None, then create a fresh list inside the function body.

Hint 2: Use 'if log is None: log = []' — the standard sentinel pattern.


#5Defensive Copy WrapperMedium
defensive copyencapsulationaliasing

Problem: Design a SafeConfig class that stores configuration as a dict but prevents external code from mutating its internal state. Both the constructor and get_settings() must return defensive copies. Consider whether shallow or deep copies are needed.

Solution
import copy

class SafeConfig:
def __init__(self, settings):
# Deep copy severs ALL references — even nested mutables
self._settings = copy.deepcopy(settings)

def get_settings(self):
# Return a deep copy so callers cannot mutate internals
return copy.deepcopy(self._settings)

def get(self, key, default=None):
# For individual values, deep copy nested mutables
value = self._settings.get(key, default)
if isinstance(value, (list, dict, set)):
return copy.deepcopy(value)
return value

# Tests
original = {"host": "localhost", "tags": ["a", "b"]}
config = SafeConfig(original)

# Mutating original dict does not affect config
original["host"] = "hacked"
original["tags"].append("c")
assert config.get("host") == "localhost"
assert config.get("tags") == ["a", "b"]

# Mutating returned settings does not affect config
s = config.get_settings()
s["host"] = "hacked"
s["tags"].append("injected")
assert config.get("host") == "localhost"
assert config.get("tags") == ["a", "b"]

# Mutating a returned list value does not affect config
tags = config.get("tags")
tags.append("injected")
assert config.get("tags") == ["a", "b"]

print("All tests passed!")

Key insight: Shallow copies (dict.copy()) only copy the top-level structure — nested mutable objects like lists still share references. Use copy.deepcopy() when your data contains nested mutables. This is the standard defensive programming pattern for encapsulating mutable state.

import copy

class SafeConfig:
  """A configuration holder that prevents external mutation.

  Both get_settings() and the constructor must use defensive
  copies so that callers cannot modify internal state.

  Args:
      settings: A dict of configuration key-value pairs.
  """

  def __init__(self, settings):
      # TODO: store a defensive copy
      pass

  def get_settings(self):
      # TODO: return a defensive copy
      pass

  def get(self, key, default=None):
      # TODO: return the value for key (safe for immutable values)
      pass
Expected Output
original = {"host": "localhost", "tags": ["a", "b"]}
config = SafeConfig(original)

# Mutating original does not affect config
original["host"] = "hacked"
assert config.get("host") == "localhost"

# Mutating returned settings does not affect config
s = config.get_settings()
s["host"] = "hacked"
assert config.get("host") == "localhost"
Hints

Hint 1: Use copy.deepcopy() in the constructor and in get_settings() to sever all references.

Hint 2: A shallow copy (dict.copy()) is not enough if values contain mutable objects like lists.


#6Predict the += BehaviorMedium
+= operator__iadd__rebinding vs mutation

Problem: Write a function that applies += to an object and tracks whether the operation created a new object (rebinding) or mutated the existing one in place. Return a dictionary with the before/after id values and whether the object identity changed.

Solution
def plus_equals_tracker(obj, addition):
id_before = id(obj)
obj += addition
id_after = id(obj)
return {
"id_before": id_before,
"id_after": id_after,
"same_object": id_before == id_after,
"value": obj,
}

# Tests — immutable types create new objects
r_int = plus_equals_tracker(10, 5)
assert r_int["same_object"] is False
assert r_int["value"] == 15

r_str = plus_equals_tracker("hello", " world")
assert r_str["same_object"] is False
assert r_str["value"] == "hello world"

r_tuple = plus_equals_tracker((1, 2), (3,))
assert r_tuple["same_object"] is False
assert r_tuple["value"] == (1, 2, 3)

# Mutable types mutate in place
r_list = plus_equals_tracker([1, 2], [3])
assert r_list["same_object"] is True
assert r_list["value"] == [1, 2, 3]

print("All tests passed!")

Key insight: For immutable types (int, str, tuple), += is equivalent to obj = obj + addition — it creates a new object and rebinds the name. For mutable types (list), += calls __iadd__ which extends the list in place and returns the same object. This difference causes subtle aliasing bugs when two names point to the same mutable object.

def plus_equals_tracker(obj, addition):
  """Track how += affects identity for different types.

  Apply obj += addition and return a dict:
  {
      'id_before': int,
      'id_after': int,
      'same_object': bool,   # id stayed the same
      'value': ...,          # the final value
  }

  Args:
      obj: An int, str, or list.
      addition: The value to += with.

  Returns:
      Dict with tracking info.
  """
  # TODO: implement this
Expected Output
plus_equals_tracker(10, 5) =>
same_object=False, value=15  (int: new object created)

plus_equals_tracker("hello", " world") =>
same_object=False, value="hello world"  (str: new object)

plus_equals_tracker([1, 2], [3]) =>
same_object=True, value=[1, 2, 3]  (list: mutated in place)
Hints

Hint 1: Capture id() before the += operation and compare with id() after.

Hint 2: For immutable types, += creates a new object. For lists, += calls __iadd__ and mutates in place.


#7Frozenset RegistryMedium
frozensethashable keysset operations

Problem: Build a PermissionRegistry that maps sets of permissions to role names. Since regular sets are unhashable and cannot be dict keys, use frozenset as keys. Support exact-match lookup and querying which roles contain a given permission.

Solution
class PermissionRegistry:
def __init__(self):
self._roles = {} # frozenset -> role_name
self._by_role = {} # role_name -> frozenset

def register(self, role_name, permissions):
key = frozenset(permissions)
self._roles[key] = role_name
self._by_role[role_name] = key

def lookup(self, permissions):
key = frozenset(permissions)
return self._roles.get(key)

def roles_with_permission(self, perm):
result = []
for role_name, perms in self._by_role.items():
if perm in perms:
result.append(role_name)
return sorted(result)

# Tests
reg = PermissionRegistry()
reg.register("viewer", {"read"})
reg.register("editor", {"read", "write"})
reg.register("admin", {"read", "write", "delete"})

# Exact match — order does not matter for sets
assert reg.lookup({"read", "write"}) == "editor"
assert reg.lookup({"write", "read"}) == "editor"
assert reg.lookup({"read"}) == "viewer"
assert reg.lookup({"read", "execute"}) is None

# Permission search
assert reg.roles_with_permission("read") == ["admin", "editor", "viewer"]
assert reg.roles_with_permission("delete") == ["admin"]
assert reg.roles_with_permission("execute") == []

print("All tests passed!")

Key insight: frozenset is the immutable, hashable counterpart to set. It supports the same membership tests and set operations but can be used as a dict key or set element. Converting to frozenset normalizes order — frozenset({"a", "b"}) equals frozenset({"b", "a"}) — making it perfect for permission bundles.

class PermissionRegistry:
  """Maps frozenset permission bundles to role names.

  Uses frozensets as dict keys because they are immutable
  and hashable, unlike regular sets.

  Methods:
      register(role_name, permissions):
          Register a role with its set of permission strings.
      lookup(permissions):
          Return the role name for an exact permission match,
          or None if not found.
      roles_with_permission(perm):
          Return a sorted list of role names that include perm.
  """

  def __init__(self):
      # TODO: initialize internal storage
      pass

  def register(self, role_name, permissions):
      # TODO: store role with frozenset key
      pass

  def lookup(self, permissions):
      # TODO: exact match lookup
      pass

  def roles_with_permission(self, perm):
      # TODO: find all roles containing this permission
      pass
Expected Output
reg = PermissionRegistry()
reg.register("viewer", {"read"})
reg.register("editor", {"read", "write"})
reg.register("admin", {"read", "write", "delete"})

reg.lookup({"read", "write"}) => "editor"
reg.lookup({"write", "read"}) => "editor"  # set order doesn't matter
reg.lookup({"read", "execute"}) => None

reg.roles_with_permission("read") => ["admin", "editor", "viewer"]
reg.roles_with_permission("delete") => ["admin"]
Hints

Hint 1: Convert the incoming set/list of permissions to a frozenset before using it as a dict key.

Hint 2: For roles_with_permission, iterate over all registered frozenset keys and check membership with 'in'.


#8Efficient String BuilderMedium
string concatenationstr.joinO(n) vs O(n²)

Problem: Implement an efficient CSV row builder. The function must use str.join() for O(n) performance, not += in a loop (which would be O(n^2) due to string immutability). Handle fields that contain commas by wrapping them in double quotes.

Solution
def build_csv_row(fields):
if not fields:
return ""
escaped = []
for f in fields:
if "," in f:
escaped.append('"' + f + '"')
else:
escaped.append(f)
return ",".join(escaped)

# Tests
assert build_csv_row(["Alice", "30", "NYC"]) == "Alice,30,NYC"
assert build_csv_row(["Bob", "age,unknown", "LA"]) == 'Bob,"age,unknown",LA'
assert build_csv_row([]) == ""
assert build_csv_row(["single"]) == "single"
assert build_csv_row(["a,b", "c,d"]) == '"a,b","c,d"'

print("All tests passed!")

Key insight: Strings are immutable, so result += piece creates a new string every iteration, copying all accumulated characters. For n pieces, total copies are 1 + 2 + ... + n = O(n^2). str.join() calculates the total length first, allocates once, then fills in each piece in a single pass — O(n) total. Always use join when building strings from multiple parts.

def build_csv_row(fields):
  """Join a list of field values into a CSV row string.

  REQUIREMENT: Must be O(n) total — do NOT use += in a loop.

  Handle edge cases:
  - Fields containing commas must be wrapped in double quotes.
  - Empty list returns an empty string.

  Args:
      fields: List of strings.

  Returns:
      A single CSV row string.
  """
  # TODO: implement this
Expected Output
build_csv_row(["Alice", "30", "NYC"]) => "Alice,30,NYC"
build_csv_row(["Bob", "age,unknown", "LA"]) => 'Bob,"age,unknown",LA'
build_csv_row([]) => ""
Hints

Hint 1: Use str.join() for O(n) performance instead of += concatenation.

Hint 2: For fields containing commas, wrap them in double quotes before joining.


#9Frozen Dataclass with Functional UpdatesHard
frozen dataclassdataclasses.replaceimmutable design

Problem: Implement a fully immutable GameCharacter using a frozen dataclass. Every "mutation" method must return a new instance via dataclasses.replace(). The character must be hashable (usable as a dict key) and support method chaining.

Solution
from dataclasses import dataclass, field, replace

@dataclass(frozen=True)
class GameCharacter:
name: str
hp: int
level: int
inventory: tuple = field(default_factory=tuple)
status_effects: frozenset = field(default_factory=frozenset)

def take_damage(self, amount):
return replace(self, hp=max(0, self.hp - amount))

def heal(self, amount, max_hp=100):
return replace(self, hp=min(max_hp, self.hp + amount))

def add_item(self, item):
return replace(self, inventory=self.inventory + (item,))

def apply_effect(self, effect):
return replace(self, status_effects=self.status_effects | {effect})

def level_up(self):
return replace(self, level=self.level + 1, hp=100)

# Tests
hero = GameCharacter("Aria", hp=100, level=1)

# Damage does not mutate original
damaged = hero.take_damage(30)
assert damaged.hp == 70
assert hero.hp == 100 # unchanged

# HP cannot go below 0
near_death = damaged.take_damage(200)
assert near_death.hp == 0

# Heal is capped
healed = damaged.heal(50, max_hp=100)
assert healed.hp == 100

# Method chaining for inventory
equipped = healed.add_item("sword").add_item("shield")
assert equipped.inventory == ("sword", "shield")
assert healed.inventory == () # unchanged

# Status effects use frozenset
cursed = equipped.apply_effect("poison").apply_effect("slow")
assert cursed.status_effects == frozenset({"poison", "slow"})

# Level up resets HP
leveled = cursed.level_up()
assert leveled.level == 2
assert leveled.hp == 100

# Hashable — can be used as dict key
state_cache = {hero: "start", leveled: "after_level_up"}
assert state_cache[hero] == "start"

print("All tests passed!")

Key insight: dataclasses.replace() is the canonical way to "modify" a frozen dataclass — it creates a new instance with the specified fields changed and all other fields copied. Using tuple for inventory and frozenset for status effects keeps the entire object tree immutable and hashable. This functional-style design eliminates aliasing bugs and enables safe use as dict keys.

from dataclasses import dataclass, field, replace

@dataclass(frozen=True)
class GameCharacter:
  """An immutable game character record.

  Attributes:
      name: Character name.
      hp: Hit points (health).
      level: Current level.
      inventory: Tuple of item strings (must be tuple, not list).
      status_effects: Frozenset of active status effect strings.
  """
  name: str
  hp: int
  level: int
  inventory: tuple = field(default_factory=tuple)
  status_effects: frozenset = field(default_factory=frozenset)

  def take_damage(self, amount):
      """Return a new character with reduced HP (min 0)."""
      # TODO: implement using replace()
      pass

  def heal(self, amount, max_hp=100):
      """Return a new character with increased HP (capped at max_hp)."""
      # TODO: implement using replace()
      pass

  def add_item(self, item):
      """Return a new character with item added to inventory."""
      # TODO: implement using replace()
      pass

  def apply_effect(self, effect):
      """Return a new character with a status effect added."""
      # TODO: implement using replace()
      pass

  def level_up(self):
      """Return a new character with level + 1 and full HP restored."""
      # TODO: implement using replace()
      pass
Expected Output
hero = GameCharacter("Aria", hp=100, level=1)
damaged = hero.take_damage(30)
# damaged.hp => 70, hero.hp => 100 (original unchanged)

healed = damaged.heal(50, max_hp=100)
# healed.hp => 100 (capped)

equipped = healed.add_item("sword").add_item("shield")
# equipped.inventory => ("sword", "shield")

cursed = equipped.apply_effect("poison")
# cursed.status_effects => frozenset({"poison"})

leveled = cursed.level_up()
# leveled.level => 2, leveled.hp => 100
Hints

Hint 1: Use dataclasses.replace(self, field=new_value) to create modified copies.

Hint 2: For inventory, concatenate tuples: self.inventory + (item,). For status_effects, use frozenset union: self.status_effects | {effect}.

Hint 3: All methods must return a NEW GameCharacter — never mutate self.


#10Pass-by-Object-Reference DebuggerHard
pass by object referencealiasingmutation tracking

Problem: Build a debugging utility that calls a function with given arguments and detects which arguments were mutated by the function. This requires snapshotting values before the call and comparing after. This directly tests your understanding of pass-by-object-reference.

Solution
import copy

def trace_mutations(func, *args):
# Deep copy each argument to capture the "before" state
snapshots = [copy.deepcopy(arg) for arg in args]

# Call the function — mutable args may be mutated in place
func(*args)

# Compare each argument to its snapshot
report = []
for i, (original, current) in enumerate(zip(snapshots, args)):
report.append({
"index": i,
"type": type(current).__name__,
"was_mutated": original != current,
"original_value": original,
"final_value": current,
})
return report

# Test 1 — mixed mutable and immutable arguments
def example(lst, num, d):
lst.append(99)
num += 1 # rebinds locally — no effect on caller
d["new_key"] = True

report = trace_mutations(example, [1, 2], 10, {"a": 1})
assert report[0]["was_mutated"] is True # list mutated
assert report[0]["final_value"] == [1, 2, 99]
assert report[1]["was_mutated"] is False # int is immutable
assert report[1]["final_value"] == 10 # unchanged at caller
assert report[2]["was_mutated"] is True # dict mutated
assert report[2]["final_value"] == {"a": 1, "new_key": True}

# Test 2 — function that rebinds (no visible mutation)
def rebinder(lst):
lst = [99, 100] # local rebinding only

report2 = trace_mutations(rebinder, [1, 2, 3])
assert report2[0]["was_mutated"] is False # rebinding is not mutation

print("All tests passed!")

Key insight: Python passes object references to functions. Mutations to mutable objects (like list.append) are visible to the caller because both share the same object. But rebinding a parameter (lst = [99]) only changes the local name — the caller's variable still points to the original object. Deep-copying before the call lets us detect which arguments were actually mutated.

def trace_mutations(func, *args):
  """Call func with args and detect which arguments were mutated.

  For each argument, record:
  - its type name
  - whether its id changed (rebinding inside func has no effect
    on caller, but we track the original object's state)
  - whether its value changed (only possible for mutable args)

  Args:
      func: A callable that takes positional arguments.
      *args: Arguments to pass to func.

  Returns:
      A list of dicts, one per argument:
      [
          {
              'index': int,
              'type': str,
              'was_mutated': bool,
              'original_value': ...,  # deep copy of value before call
              'final_value': ...,     # value after call
          },
          ...
      ]
  """
  # TODO: implement this
Expected Output
def example(lst, num, d):
  lst.append(99)
  num += 1
  d["new_key"] = True

report = trace_mutations(example, [1, 2], 10, {"a": 1})
# report[0]: was_mutated=True  (list was mutated)
# report[1]: was_mutated=False (int is immutable)
# report[2]: was_mutated=True  (dict was mutated)
Hints

Hint 1: Use copy.deepcopy() to snapshot each argument before calling the function.

Hint 2: After the call, compare each argument's current value to its snapshot to detect mutation.

Hint 3: Immutable objects cannot be mutated, so their before/after values always match.


#11Immutable Transaction LogHard
frozen dataclassimmutable designfunctional updateshashable

Problem: Design a fully immutable transaction log system using frozen dataclasses. The log must support append, filtering, and slicing — all returning new log instances. The original log must never change. The log must be hashable for use as a cache key.

Solution
from dataclasses import dataclass, field, replace

@dataclass(frozen=True)
class Transaction:
tx_id: int
amount: float
description: str

@dataclass(frozen=True)
class TransactionLog:
entries: tuple = field(default_factory=tuple)
balance: float = 0.0

def append(self, tx):
return replace(
self,
entries=self.entries + (tx,),
balance=self.balance + tx.amount,
)

def filter_by_min_amount(self, min_amount):
filtered = tuple(
tx for tx in self.entries if abs(tx.amount) >= min_amount
)
new_balance = sum(tx.amount for tx in filtered)
return replace(self, entries=filtered, balance=new_balance)

def last_n(self, n):
sliced = self.entries[-n:] if n > 0 else ()
new_balance = sum(tx.amount for tx in sliced)
return replace(self, entries=sliced, balance=new_balance)

def __len__(self):
return len(self.entries)

# Tests
log = TransactionLog()
log1 = log.append(Transaction(1, 100.0, "deposit"))
log2 = log1.append(Transaction(2, -30.0, "withdrawal"))
log3 = log2.append(Transaction(3, 5.0, "interest"))

# Original is unchanged
assert log.balance == 0.0
assert len(log) == 0

# Running balance is correct
assert log3.balance == 75.0
assert len(log3) == 3

# Each log is a separate snapshot
assert log1.balance == 100.0
assert log2.balance == 70.0

# Filter — only large transactions
big = log3.filter_by_min_amount(10.0)
assert len(big) == 2
assert big.balance == 70.0 # 100.0 + (-30.0)

# Last N
recent = log3.last_n(2)
assert len(recent) == 2
assert recent.balance == -25.0 # -30.0 + 5.0
assert recent.entries[0].tx_id == 2

# Hashable — works as dict key
cache = {log3: "state_snapshot"}
assert cache[log3] == "state_snapshot"

# Immutable — assignment raises error
try:
log3.balance = 999.0
assert False, "Should have raised FrozenInstanceError"
except AttributeError:
pass # FrozenInstanceError is a subclass of AttributeError

print("All tests passed!")

Key insight: This pattern — frozen dataclasses with tuple-based collections and replace() for updates — is the foundation of immutable design in Python. Every operation returns a new object, preserving all previous states as an audit trail. The trade-off is O(n) per append (tuple concatenation copies all elements), but the safety guarantees (no aliasing bugs, hashability, thread safety) are worth it for most domain objects. For high-frequency append workloads, consider batching or using persistent data structures.

from dataclasses import dataclass, field, replace

@dataclass(frozen=True)
class Transaction:
  """A single immutable transaction record."""
  tx_id: int
  amount: float
  description: str

@dataclass(frozen=True)
class TransactionLog:
  """An immutable, append-only transaction log.

  Every 'modification' returns a new TransactionLog.
  The log is hashable and can be used as a dict key.

  Attributes:
      entries: Tuple of Transaction objects.
      balance: Running balance (float).
  """
  entries: tuple = field(default_factory=tuple)
  balance: float = 0.0

  def append(self, tx):
      """Return a new log with the transaction added
      and balance updated."""
      # TODO: implement

  def filter_by_min_amount(self, min_amount):
      """Return a new log with only transactions
      where abs(amount) >= min_amount.
      Recalculate the balance from filtered entries."""
      # TODO: implement

  def last_n(self, n):
      """Return a new log with only the last n entries.
      Recalculate the balance from those entries."""
      # TODO: implement

  def __len__(self):
      # TODO: implement
      pass
Expected Output
log = TransactionLog()
log1 = log.append(Transaction(1, 100.0, "deposit"))
log2 = log1.append(Transaction(2, -30.0, "withdrawal"))
log3 = log2.append(Transaction(3, 5.0, "interest"))

len(log3) => 3
log3.balance => 75.0
log.balance => 0.0  (original unchanged)

big = log3.filter_by_min_amount(10.0)
len(big) => 2, big.balance => 70.0

recent = log3.last_n(2)
len(recent) => 2, recent.balance => -25.0
Hints

Hint 1: Use dataclasses.replace() to create new TransactionLog instances with updated entries and balance.

Hint 2: For filter and last_n, build a new tuple of entries and recalculate balance by summing amounts.

Hint 3: The balance in filter/last_n should be sum(tx.amount for tx in filtered_entries).

© 2026 EngineersOfAI. All rights reserved.