Skip to main content

Copying and Mutability

What You'll Learn

Why modifying one variable sometimes changes another, the difference between shallow and deep copies, and how to protect your data.

Mutable vs Immutable

Immutable objects cannot be changed after creation. Every "change" creates a new object:

# Immutable types: int, float, str, bool, tuple, frozenset
x = 10
y = x
x = 20
print(y) # 10 — y is unaffected, x now points to a new object

Mutable objects can be changed in place. Multiple variables can point to the same object:

# Mutable types: list, dict, set
a = [1, 2, 3]
b = a # b points to the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] — a is also changed!

How Assignment Works in Python

Python variables are labels pointing to objects, not boxes containing values:

a = [1, 2, 3] # 'a' points to a list object in memory
b = a # 'b' points to the SAME list object
c = [1, 2, 3] # 'c' points to a DIFFERENT list object (same content)

print(a is b) # True — same object in memory
print(a is c) # False — different objects
print(a == c) # True — same content

Use id() to see the memory address:

print(id(a)) # e.g. 140234567
print(id(b)) # same number
print(id(c)) # different number

Shallow Copy

A shallow copy creates a new container, but the elements inside still point to the same objects:

import copy

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

# Three ways to shallow copy a list
shallow1 = original[:] # slice
shallow2 = list(original) # list()
shallow3 = original.copy() # .copy()
shallow4 = copy.copy(original) # copy module

# Changing a top-level element is safe
shallow1[0] = 99
print(original) # [1, 2, [3, 4]] — original unchanged

# Changing a nested mutable element affects BOTH
shallow1[2].append(5)
print(original) # [1, 2, [3, 4, 5]] — original changed!
print(shallow1) # [99, 2, [3, 4, 5]]

Deep Copy

A deep copy creates completely independent copies of everything, recursively:

import copy

original = [1, 2, [3, 4]]
deep = copy.deepcopy(original)

deep[2].append(5)
print(original) # [1, 2, [3, 4]] — unchanged
print(deep) # [1, 2, [3, 4, 5]] — only deep changed

Copying Dictionaries

user = {
"name": "Alice",
"scores": [90, 85, 92]
}

# Shallow copy — nested list is still shared
shallow = user.copy() # or dict(user)
shallow["name"] = "Bob" # safe — string is immutable
shallow["scores"].append(100)
print(user["scores"]) # [90, 85, 92, 100] — shared!

# Deep copy — completely independent
import copy
deep = copy.deepcopy(user)
deep["scores"].append(100)
print(user["scores"]) # [90, 85, 92] — unchanged

Accidental Mutation — Common Bug

# ❌ Bug: default mutable argument
def add_item(item, lst=[]): # the list is created ONCE, shared across calls
lst.append(item)
return lst

print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] — bug! expected ['b']

# ✅ Fix: use None as default
def add_item(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst

Immutable Containers

Use frozenset or tuple when you need an immutable version of set/list:

# frozenset — immutable set (can be used as dict key)
immutable_tags = frozenset({"python", "beginner", "tutorial"})
immutable_tags.add("new") # AttributeError — can't modify

# Use tuples for fixed records
point = (10, 20) # immutable pair
rgb = (255, 128, 0) # immutable color

When to Copy

SituationWhat to Use
Simple list/dict, no nesting.copy() or [:]
Nested data structurescopy.deepcopy()
Function argument safetyMake a copy at the start
Storing history/snapshotscopy.deepcopy() before mutating
Read-only dataUse tuples or frozensets

Real-World Pattern: Processing Without Modifying Original

import copy

def process_records(records: list[dict]) -> list[dict]:
"""Process records without modifying the originals."""
working_copy = copy.deepcopy(records)

for record in working_copy:
record["name"] = record["name"].strip().title()
record["score"] = round(record["score"], 2)

return working_copy

original_data = [{"name": " alice ", "score": 95.678}]
processed = process_records(original_data)

print(original_data) # [{'name': ' alice ', 'score': 95.678}] — unchanged
print(processed) # [{'name': 'Alice', 'score': 95.68}]

Quick Reference

# Is it mutable?
# Mutable: list, dict, set
# Immutable: int, float, str, bool, tuple, frozenset

# Check identity (same object)
a is b

# Shallow copy
lst[:] # list slice
list(lst) # new list from list
dct.copy() # dict shallow copy
import copy; copy.copy(obj)

# Deep copy
import copy
copy.deepcopy(obj)

# Safe default argument
def fn(items=None):
if items is None:
items = []

What's Next

Lesson 5: Choosing the Right Container