The copy module provides copy() and deepcopy(). Understanding when to use each prevents subtle bugs with mutable objects.

The Problem

Assignment doesn't copy—it creates another reference:

original = [1, 2, [3, 4]]
reference = original
 
reference[0] = 999
print(original)  # [999, 2, [3, 4]] - original changed!

Both variables point to the same object.

Shallow Copy

copy.copy() creates a new container but doesn't copy nested objects:

import copy
 
original = [1, 2, [3, 4]]
shallow = copy.copy(original)
 
# Different list object
shallow[0] = 999
print(original)  # [1, 2, [3, 4]] - unchanged
 
# But nested list is shared
shallow[2][0] = 999
print(original)  # [1, 2, [999, 4]] - changed!

Visualized:

original ──→ [ 1, 2, ─→ [3, 4] ]
                     ↗
shallow ───→ [ 1, 2, ─┘        ]

Deep Copy

copy.deepcopy() recursively copies everything:

import copy
 
original = [1, 2, [3, 4]]
deep = copy.deepcopy(original)
 
# Completely independent
deep[2][0] = 999
print(original)  # [1, 2, [3, 4]] - unchanged
print(deep)      # [1, 2, [999, 4]]

Visualized:

original ──→ [ 1, 2, ─→ [3, 4] ]

deep ──────→ [ 1, 2, ─→ [3, 4] ]  (separate copy)

When to Use Which

SituationUse
Flat list/dict (no nesting)copy() or slice ([:])
Nested structuresdeepcopy()
Immutable contents onlyAssignment is fine
Performance criticalcopy() if possible
Uncertaindeepcopy() is safer

Built-in Shallow Copy Methods

Many types have built-in shallow copy:

# Lists
new_list = old_list[:]
new_list = list(old_list)
new_list = old_list.copy()
 
# Dicts
new_dict = dict(old_dict)
new_dict = old_dict.copy()
new_dict = {**old_dict}
 
# Sets
new_set = set(old_set)
new_set = old_set.copy()

All of these are shallow copies.

Common Gotchas

Default Mutable Arguments

# BUG: default list is shared
def append_to(item, target=[]):
    target.append(item)
    return target
 
append_to(1)  # [1]
append_to(2)  # [1, 2] - not [2]!
 
# FIX: use None and copy
def append_to(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

Class Attributes

class Team:
    members = []  # Shared across all instances!
 
t1 = Team()
t2 = Team()
t1.members.append("Alice")
print(t2.members)  # ["Alice"] - oops
 
# FIX: initialize in __init__
class Team:
    def __init__(self):
        self.members = []  # Each instance gets its own

Copying Objects with Circular References

deepcopy handles cycles:

import copy
 
a = [1, 2]
a.append(a)  # Circular reference
 
b = copy.deepcopy(a)  # Works correctly
print(b[2] is b)  # True - cycle preserved in copy

Custom Copy Behavior

Define __copy__ and __deepcopy__ for custom classes:

import copy
 
class Config:
    def __init__(self, settings):
        self.settings = settings
        self._cache = {}  # Don't copy this
    
    def __copy__(self):
        # Shallow copy, fresh cache
        new = Config.__new__(Config)
        new.settings = self.settings
        new._cache = {}
        return new
    
    def __deepcopy__(self, memo):
        # Deep copy settings, fresh cache
        new = Config.__new__(Config)
        new.settings = copy.deepcopy(self.settings, memo)
        new._cache = {}
        return new

The memo dict prevents infinite loops with circular references.

Performance Comparison

import copy
import timeit
 
data = [list(range(100)) for _ in range(100)]
 
# Shallow copy - fast
timeit.timeit(lambda: copy.copy(data), number=10000)
# ~0.02 seconds
 
# Deep copy - slower
timeit.timeit(lambda: copy.deepcopy(data), number=10000)
# ~2.5 seconds

deepcopy is ~100x slower for nested structures. Use it when you need it, not by default.

Practical Example: Undo Stack

import copy
 
class Editor:
    def __init__(self):
        self.document = {"title": "", "content": []}
        self.history = []
    
    def save_state(self):
        # Deep copy to preserve complete state
        self.history.append(copy.deepcopy(self.document))
    
    def undo(self):
        if self.history:
            self.document = self.history.pop()
    
    def add_paragraph(self, text):
        self.save_state()
        self.document["content"].append(text)
 
editor = Editor()
editor.add_paragraph("Hello")
editor.add_paragraph("World")
editor.undo()
print(editor.document["content"])  # ["Hello"]

Quick Reference

import copy
 
# Shallow copy (new container, shared contents)
new = copy.copy(original)
 
# Deep copy (everything is new)
new = copy.deepcopy(original)
 
# Built-in alternatives (all shallow)
new_list = old_list[:]
new_dict = {**old_dict}
new_set = old_set.copy()
MethodNew ContainerNew Nested Objects
Assignment (=)NoNo
copy.copy()YesNo
copy.deepcopy()YesYes

When in doubt about mutable state: deepcopy. When you know your structure is flat: copy or slicing.

React to this post: