Python Mutable Practice Problems & Exercises
Practice: Mutable vs Immutable Revisited
← Back to lessonProblem: Write a function that takes any Python object and returns Key insight: 'mutable' or 'immutable' based on its type. Cover all built-in types listed in the docstring.Solution
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 thisExpected 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.
Problem: Write a function that takes two objects and returns a dictionary describing whether they are equal, identical, and their Key insight: id values. This helps visualize when Python reuses objects vs creates new ones.Solution
== 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 thisExpected 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.
Problem: Demonstrate that a tuple's immutability is shallow — the tuple structure cannot change, but mutable elements inside it can be modified. Append Key insight: Tuple immutability means you cannot add, remove, or reassign elements at the tuple level (99 to every list inside the given tuple and return the original tuple object.Solution
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 thisExpected 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.
Problem: The function above has the classic mutable default argument bug. Each call without an explicit Key insight: Python evaluates default arguments once at function definition time. A mutable default like log argument shares the same list, causing events to accumulate. Fix it using the None sentinel pattern.Solution
[] 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 logExpected 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.
Problem: Design a Key insight: Shallow copies (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
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)
passExpected 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.
Problem: Write a function that applies Key insight: For immutable types (+= 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
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 thisExpected 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.
Problem: Build a Key insight: 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
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
passExpected 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'.
Problem: Implement an efficient CSV row builder. The function must use Key insight: Strings are immutable, so 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
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 thisExpected 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.
Problem: Implement a fully immutable Key insight: 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
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()
passExpected 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 => 100Hints
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.
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. Key insight: Python passes object references to functions. Mutations to mutable objects (like Solution
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 thisExpected 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.
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. Key insight: This pattern — frozen dataclasses with tuple-based collections and Solution
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
passExpected 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.0Hints
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).
