backend / python core / 17_shallow_vs_deep_copy.md

Shallow vs deep copy

3 min read source

Shallow vs deep copy

copy.copy() makes a new outer object referencing the same nested objects. copy.deepcopy() recursively copies everything. The trap fires when the outer object contains mutables — appending to a nested list mutates both the original and the copy.

The classic shallow-copy trap

import copy

a = [[1, 2], [3, 4]]
b = copy.copy(a)
b[0].append(99)
print(a)            # [[1, 2, 99], [3, 4]]    ← original mutated too
print(b)            # [[1, 2, 99], [3, 4]]
print(a is b)       # False                    ← outer is a new object
print(a[0] is b[0]) # True                     ← inner list shared

The outer list is fresh, so b.append(...) would only affect b. But b[0] is the same list object as a[0]. Mutating it via .append is visible everywhere it’s referenced.

Detailed comparison

import copy

# Original nested list
original = [[1, 2, 3], [4, 5, 6]]

# Shallow copy
shallow = copy.copy(original)

# Deep copy
deep = copy.deepcopy(original)

# Let's modify the nested list
original[0][0] = 'X'

print("Original:", original)     # Original: [['X', 2, 3], [4, 5, 6]]
print("Shallow:", shallow)       # Shallow:  [['X', 2, 3], [4, 5, 6]]
print("Deep:", deep)            # Deep:     [[1, 2, 3], [4, 5, 6]]

Key differences:

# Shallow Copy:
# 1. Creates a new object but references the same nested objects
# 2. Only copies the first level of the object
# 3. Changes to nested objects affect both original and copy
list1 = [1, [2, 3]]
list2 = copy.copy(list1)
list1[1][0] = 'changed'
print(list2[1][0])  # Output: 'changed'

# Deep Copy:
# 1. Creates a new object and recursively copies all nested objects
# 2. Creates independent copies at all levels
# 3. Changes to nested objects don't affect each other
list1 = [1, [2, 3]]
list2 = copy.deepcopy(list1)
list1[1][0] = 'changed'
print(list2[1][0])  # Output: 2

When to use which:

# Use shallow copy when:
# 1. Your object contains only immutable objects (numbers, strings, tuples)
numbers = [1, 2, 3]
shallow_numbers = copy.copy(numbers)  # Safe because numbers are immutable

# Use deep copy when:
# 2. Your object contains nested mutable objects (lists, dictionaries)
nested_dict = {'a': [1, 2], 'b': [3, 4]}
deep_dict = copy.deepcopy(nested_dict)  # Safe for nested structures

Idioms that produce shallow copies

These all behave the same as copy.copy(x) for their respective types:

list2 = list1[:]              # slicing
list2 = list(list1)           # constructor
list2 = list1.copy()          # method
list2 = [*list1]              # unpacking

dict2 = dict1.copy()
dict2 = dict(dict1)
dict2 = {**dict1}

set2 = set1.copy()
set2 = set(set1)
set2 = {*set1}

If list1 contains nested mutables, all of the above share them.

When deep copy itself trips you up

import copy

class Connection:
    def __init__(self): self.socket = open_real_socket()

data = {"conn": Connection(), "items": [1, 2, 3]}
copy.deepcopy(data)            # tries to deepcopy the Connection
                               # — may fail or duplicate the socket

For objects that aren’t safe to copy (open files, locks, sockets, DB connections), implement __copy__ / __deepcopy__ to return self, raise, or share the resource intentionally. Or: don’t put them in structures you’ll deep-copy.

Interview angle

  • Q: “What does copy.copy([[1, 2], [3, 4]]) return, and what happens when you mutate b[0]?” — same outer, shared inner. Mutation visible on both.
  • Q: “When would you reach for deepcopy?” — config/cache/fixture defaults with nested mutable state.
  • Follow-up: “What’s the cost of deepcopy?” — 10–100× shallow; recursive walk; raises the question of whether values should be immutable instead.

See tricky_questions/48_dict_shallow_copy_trap.md, tricky_questions/01_mutable_default_arguments.md, tricky_questions/44_dict_fromkeys_shared_default.md.