Learnixo
Back to blog
AI Systemsintermediate

What is Mutability?

Understand Python mutability: which types are mutable vs immutable, why it matters for function arguments and shared state, and how to safely copy objects in AI/ML code.

Asma Hafeez KhanMay 16, 20265 min read
PythonMutabilityImmutabilityCopyPass by Reference
Share:š•

Mutable vs Immutable

Mutable objects can be changed after creation. Immutable objects cannot — any "modification" creates a new object.

Python
# 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 address

Why It Matters: Shared References

Because variables are references to objects, mutation through one variable affects all variables pointing to the same object:

Python
# 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 objects

Functions and Mutability

This matters most when passing objects to functions:

Python
# 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:

Python
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"] — unchanged

Visual:

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:

Python
# 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"] — independent

Rule: Never use mutable objects (list, dict, set) as default argument values.


Practical Patterns

Python
# 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 enforced

Which 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 None and create inside the function
  • Prefer returning new objects over mutating inputs — easier to reason about

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.