Python Essentials for AI Engineers · Lesson 8 of 36
What is Mutability?
Mutable vs Immutable
Mutable objects can be changed after creation. Immutable objects cannot — any "modification" creates a new object.
# Mutable types: list, dict, set, bytearray, custom classes
medications = ["warfarin", "aspirin"]
medications.append("metformin") # SAME object, modified in place
print(id(medications)) # Same memory address before and after
# Immutable types: int, float, str, bool, tuple, frozenset, bytes
drug_name = "warfarin"
drug_name = drug_name.upper() # NEW object "WARFARIN" — "warfarin" unchanged
print(id(drug_name)) # Different memory addressWhy It Matters: Shared References
Because variables are references to objects, mutation through one variable affects all variables pointing to the same object:
# Both a and b point to the SAME list
a = ["warfarin", "aspirin"]
b = a
b.append("metformin")
print(a) # ["warfarin", "aspirin", "metformin"] — a changed too!
print(b) # ["warfarin", "aspirin", "metformin"]
print(a is b) # True — same object
# Immutable: rebinding doesn't affect the original
x = "warfarin"
y = x
y = y.upper() # y now points to a NEW string "WARFARIN"
print(x) # "warfarin" — unchanged
print(x is y) # False — different objectsFunctions and Mutability
This matters most when passing objects to functions:
# Mutable argument: the function modifies the original
def add_disclaimer(drug_list: list[str]) -> None:
drug_list.append("VERIFY WITH PHARMACIST") # Modifies the caller's list!
my_drugs = ["warfarin", "aspirin"]
add_disclaimer(my_drugs)
print(my_drugs) # ["warfarin", "aspirin", "VERIFY WITH PHARMACIST"] — changed!
# Immutable argument: the function cannot modify the original
def shout(text: str) -> str:
text = text.upper() # Creates new string — original unchanged
return text
drug = "warfarin"
result = shout(drug)
print(drug) # "warfarin" — unchanged
print(result) # "WARFARIN"
# Safe pattern: don't modify input lists — build a new one
def add_disclaimer_safe(drug_list: list[str]) -> list[str]:
return drug_list + ["VERIFY WITH PHARMACIST"] # New list, original untouched
my_drugs = ["warfarin", "aspirin"]
result = add_disclaimer_safe(my_drugs)
print(my_drugs) # ["warfarin", "aspirin"] — unchanged
print(result) # ["warfarin", "aspirin", "VERIFY WITH PHARMACIST"]Shallow vs Deep Copy
When you need an independent copy of a mutable object:
import copy
original = {
"patient_id": "P001",
"medications": ["warfarin", "aspirin"],
"labs": {"inr": 2.4, "hba1c": 7.2},
}
# Shallow copy: copies the outer container, shares inner objects
shallow = original.copy() # For dicts
shallow = list(original_list) # For lists
shallow = copy.copy(original) # Generic shallow copy
# Mutation of inner objects STILL affects original with shallow copy
shallow["medications"].append("metformin")
print(original["medications"]) # ["warfarin", "aspirin", "metformin"] — changed!
# Inner list is SHARED between original and shallow
# Deep copy: recursively copies everything
deep = copy.deepcopy(original)
deep["medications"].append("metformin")
print(original["medications"]) # ["warfarin", "aspirin"] — unchangedVisual:
original ──→ {"medications": ──→ ["warfarin", "aspirin"]}
↑
shallow ──→ {"medications": ─────────┘ (shared inner list)
deep ──→ {"medications": ──→ ["warfarin", "aspirin"]} (independent copy)The Mutable Default Argument Bug
One of Python's most common gotchas:
# BUG: default list is created ONCE when function is defined
def add_drug(drug: str, drug_list: list = []) -> list:
drug_list.append(drug)
return drug_list
print(add_drug("warfarin")) # ["warfarin"]
print(add_drug("aspirin")) # ["warfarin", "aspirin"] — not ["aspirin"]!
print(add_drug("metformin")) # ["warfarin", "aspirin", "metformin"]
# The same [] is reused on every call — it's shared state
# FIX: use None as default, create new list inside
def add_drug_fixed(drug: str, drug_list: list | None = None) -> list:
if drug_list is None:
drug_list = [] # New list each call
drug_list.append(drug)
return drug_list
print(add_drug_fixed("warfarin")) # ["warfarin"]
print(add_drug_fixed("aspirin")) # ["aspirin"] — independentRule: Never use mutable objects (list, dict, set) as default argument values.
Practical Patterns
# Pattern 1: Return new objects instead of mutating inputs
def normalize_drug_names(drugs: list[str]) -> list[str]:
return [drug.lower().strip() for drug in drugs] # New list
# Pattern 2: Use tuple for immutable config that shouldn't be changed
SUPPORTED_MODELS = ("gpt-4o", "gpt-4o-mini", "claude-sonnet-4-6")
# If this were a list, any function could accidentally modify it
# Pattern 3: Copy before modifying when you need to preserve original
def update_config(base_config: dict, overrides: dict) -> dict:
"""Return new config with overrides applied, leaving base untouched."""
new_config = base_config.copy() # Shallow copy OK for flat dicts
new_config.update(overrides)
return new_config
base = {"temperature": 0, "model": "gpt-4o"}
dev_config = update_config(base, {"temperature": 0.7, "max_tokens": 100})
print(base) # {"temperature": 0, "model": "gpt-4o"} — unchanged
print(dev_config) # {"temperature": 0.7, "model": "gpt-4o", "max_tokens": 100}
# Pattern 4: Detect mutation bugs with tuple wrapping
def process_fixed_items(items: tuple[str, ...]) -> list[str]:
"""Take tuple (immutable) — caller cannot accidentally pass a mutable list."""
return [item.upper() for item in items]
process_fixed_items(("warfarin", "aspirin")) # Works
process_fixed_items(["warfarin", "aspirin"]) # Also works (duck typing)
# Type hint documents intent even if not enforcedWhich Types are Mutable?
| Type | Mutable | Can be Dict Key? | Can be in Set? |
|---|---|---|---|
| int, float, complex | No | Yes | Yes |
| str | No | Yes | Yes |
| bool | No | Yes | Yes |
| tuple (of hashable items) | No | Yes | Yes |
| frozenset | No | Yes | Yes |
| list | Yes | No | No |
| dict | Yes | No | No |
| set | Yes | No | No |
| Custom class (default) | Yes | No (unless __hash__ defined) | No |
Summary
- Mutation changes the object in place — all variables pointing to it see the change
- Rebinding makes a variable point to a new object — other variables are unaffected
- Shallow copy copies the outer container but shares nested objects
- Deep copy creates a fully independent copy
- Never use mutable defaults — use
Noneand create inside the function - Prefer returning new objects over mutating inputs — easier to reason about