Problem Solved

Python · shallow vs deep copy

Home › Courses › Python › Problems › Shallow vs deep copy
🐍 Concept · 4 min read

Shallow vs deep copy.
How deep do you need to go?

Assignment doesn't copy in Python — it creates a new reference. To actually copy, you need copy.copy() (shallow) or copy.deepcopy() (deep). The difference matters most with nested structures. A shallow copy of a list of lists shares the inner lists — modifying one via the copy changes the original. A deep copy creates independent inner lists. This page covers the difference, when to use each, and the memory model that makes it all make sense.

Short answer: a = b doesn't copy — it aliases. Use list(a), a[:], or a.copy() for a shallow copy (new container, shared items). Use copy.deepcopy(a) for a deep copy (new container, recursively new items). Shallow is fine for flat containers of immutable items. Deep is needed for nested containers or containers of mutable objects. Assignment, shallow, and deep are the three levels — most bugs come from assuming one when you have another.

✅ The three levels

Assignment, shallow copy, deep copy.

levels.py
import copy

# Level 1: Assignment — no copy at all
original = [1, 2, 3]
assigned = original

assigned.append(4)
print(original)  # [1, 2, 3, 4]  — same object!
print(original is assigned)  # True

# Level 2: Shallow copy — new container, shared items
original = [1, 2, 3]
shallow = list(original)  # or original[:] or original.copy()

shallow.append(4)
print(original)  # [1, 2, 3]  — original unchanged
print(shallow)   # [1, 2, 3, 4]
print(original is shallow)  # False

# Level 3: Deep copy — new container, recursively new items
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)

deep[0].append(99)
print(original)  # [[1, 2], [3, 4]]  — inner lists unchanged
print(deep)      # [[1, 2, 99], [3, 4]]
print(original[0] is deep[0])  # False

# The shallow copy trap with nested data:
original = [[1, 2], [3, 4]]
shallow = list(original)

shallow[0].append(99)
print(original)  # [[1, 2, 99], [3, 4]]  — modified!
print(shallow)   # [[1, 2, 99], [3, 4]]
print(original[0] is shallow[0])  # True — same inner list!
The three levels:

Assignment (b = a):
  → New name, same object
  → a and b are aliases
  → Modifying one modifies the other
  → id(a) == id(b)

Shallow copy (b = copy.copy(a)):
  → New outer container
  → Same inner items (references shared)
  → Modifying b's items via the container
    may affect a (if items are mutable)
  → id(a) != id(b)
  → id(a[0]) == id(b[0])  — same inner object

Deep copy (b = copy.deepcopy(a)):
  → New outer container
  → New inner items, recursively
  → Modifying b never affects a
  → id(a) != id(b)
  → id(a[0]) != id(b[0])  — different inner objects

Memory model:

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

  Assignment:
    original ──┐
               ├──→ [ [1, 2], [3, 4] ]
    assigned ──┘
    (both point to the same list object)

  Shallow copy:
    original ──→ [ A, B ]    where A = [1, 2], B = [3, 4]
    shallow  ──→ [ A, B ]    (new list, same A and B)

  Deep copy:
    original ──→ [ A, B ]
    deep     ──→ [ A', B' ]   (new list, new A' and B')

Why "shallow" vs "deep":
  Shallow goes one level — the outer container is new.
  Deep goes all the way — every nested object is new.

What gets deep-copied:
  → Lists
  → Dicts
  → Sets
  → Custom objects (via __deepcopy__ or __dict__)
  → Tuples (if they contain mutable items)
  → Everything, recursively

What doesn't:
  → Immutable objects (int, str, tuple of immutables)
    — deepcopy returns the same object
  → Modules, functions, classes
    — deepcopy has no concept of "copy a module"
  → File handles, sockets, locks
    — deepcopy raises TypeError

For a flat list of ints or strings:
  shallow copy is enough.
  deep copy works but is slower.
  Use shallow.

For a nested list, or a list of dicts, or
a dict of lists:
  shallow copy is usually NOT enough.
  Use deepcopy.

The rule:
  If your structure has any mutable container
  inside another container, you probably need
  deepcopy.
  If everything is immutable (ints, strings,
  tuples of immutables), shallow copy is safe.

✅ When each breaks

The bugs, and why they happen.

bugs.py
import copy

# Bug 1: assignment, not copy
original = [1, 2, 3]
copy_wrong = original
copy_wrong.append(4)
print(original)  # [1, 2, 3, 4]  — bug!

# Fix: use a real copy
original = [1, 2, 3]
copy_right = list(original)
copy_right.append(4)
print(original)  # [1, 2, 3]  — correct

# Bug 2: shallow copy of nested data
original = [[1, 2], [3, 4]]
shallow = list(original)
shallow[0].append(99)
print(original)  # [[1, 2, 99], [3, 4]]  — bug!

# Fix: use deepcopy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0].append(99)
print(original)  # [[1, 2], [3, 4]]  — correct

# Bug 3: shallow copy of dict with list values
original = {'a': [1, 2], 'b': [3, 4]}
shallow = dict(original)
shallow['a'].append(99)
print(original['a'])  # [1, 2, 99]  — bug!

# Fix: deepcopy
original = {'a': [1, 2], 'b': [3, 4]}
deep = copy.deepcopy(original)
deep['a'].append(99)
print(original['a'])  # [1, 2]  — correct

# Bug 4: shallow copy of a list of dicts
original = [{'name': 'Alice'}, {'name': 'Bob'}]
shallow = list(original)
shallow[0]['name'] = 'Charlie'
print(original[0])  # {'name': 'Charlie'}  — bug!

# Fix: deepcopy
original = [{'name': 'Alice'}, {'name': 'Bob'}]
deep = copy.deepcopy(original)
deep[0]['name'] = 'Charlie'
print(original[0])  # {'name': 'Alice'}  — correct
The four bugs:

Bug 1: Not copying at all
  original = [1, 2, 3]
  copy = original    # same object
  copy.append(4)     # modifies both

  Fix: list(original), original[:], or original.copy()

Bug 2: Shallow copy of nested list
  original = [[1, 2], [3, 4]]
  copy = list(original)   # new outer, same inner
  copy[0].append(99)      # modifies original[0]

  Fix: copy.deepcopy(original)

Bug 3: Shallow copy of dict with list values
  original = {'a': [1, 2]}
  copy = dict(original)   # new dict, same list value
  copy['a'].append(99)    # modifies original['a']

  Fix: copy.deepcopy(original)

Bug 4: Shallow copy of list of dicts
  original = [{'name': 'Alice'}]
  copy = list(original)   # new outer, same dict
  copy[0]['name'] = 'X'   # modifies original[0]

  Fix: copy.deepcopy(original)

The pattern:

  All four bugs are variations of the same mistake:
  a mutable object is shared between the original
  and the "copy."

  Rule:
    If your structure contains any mutable object
    at any depth, use deepcopy.
    If everything is immutable, shallow copy is safe.

When shallow is enough:

  ✅ List of ints: [1, 2, 3]
  ✅ List of strings: ['a', 'b', 'c']
  ✅ List of tuples of immutables: [(1, 2), (3, 4)]
  ✅ Dict with string values: {'a': 'x', 'b': 'y'}
  ✅ Dict with int values: {'a': 1, 'b': 2}
  ✅ Set of strings: {'a', 'b'}

  These are safe because:
    - The items are immutable
    - You can't mutate them through the copy
    - The only mutations are to the outer container
    - Shallow copy gives you a new outer container

When shallow is NOT enough:

  ❌ List of lists: [[1, 2], [3, 4]]
  ❌ List of dicts: [{'a': 1}, {'b': 2}]
  ❌ Dict of lists: {'a': [1, 2]}
  ❌ Dict of dicts: {'a': {'x': 1}}
  ❌ List of custom objects with mutable attrs
  ❌ Any nested mutable structure

The deepcopy cost:

  deepcopy is slower than shallow.
  It recurses through everything.
  For a list of 1000 ints: shallow is microseconds,
  deepcopy is milliseconds.
  For deeply nested data: deepcopy can be much
  slower.

  Optimization: shallow copy when you can.
  Use deepcopy only when the structure requires it.
  If you're copying the same structure many times,
  consider whether you need copies at all — often
  a functional approach (don't mutate) is better.

The functional alternative:

  Instead of copying-then-mutating:
    copy = deepcopy(original)
    copy[0].append(99)
  
  Build a new structure:
    new = [row + [99] if i == 0 else row
           for i, row in enumerate(original)]
  
  This creates a new structure without modifying
  the original. No deepcopy needed. The downside:
  more code for complex transformations.

  For simple cases (adding to one inner list),
  deepcopy is clearer. For complex transformations,
  the comprehension is better. Choose based on
  readability, not on avoiding deepcopy at all costs.

✅ Copy methods and recipes

Every built-in copy method.

methods.py
import copy

# Lists — all shallow copies
lst = [1, 2, 3]
lst.copy()          # method (3.3+)
list(lst)           # constructor
lst[:]              # full slice
copy.copy(lst)      # module function
# All equivalent, all shallow.

# Dicts — all shallow copies
d = {'a': 1, 'b': 2}
d.copy()
dict(d)
copy.copy(d)

# Sets — all shallow copies
s = {1, 2, 3}
s.copy()
set(s)
copy.copy(s)

# Tuples — immutable, so copy is the same tuple
t = (1, 2, 3)
copy.copy(t) is t          # True — no new object
copy.deepcopy(t)           # Same tuple (all immutables)

# Tuple with mutable contents
t = ([1, 2], [3, 4])
copy.copy(t) is t          # True — still same tuple
copy.deepcopy(t)           # New tuple, new inner lists

# Strings — immutable
s = 'hello'
copy.copy(s) is s          # True

# Custom objects — shallow
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f'Point({self.x}, {self.y})'

p = Point(1, 2)
p2 = copy.copy(p)
p2.x = 100
print(p)   # Point(1, 2) — original unchanged (shallow)
print(p2)  # Point(100, 2)

# Custom objects — deep
p3 = copy.deepcopy(p)
p3.x = 999
print(p)   # Point(1, 2) — original unchanged (deep)

# Custom objects with __dict__ — deepcopy works
class Container:
    def __init__(self):
        self.items = [1, 2, 3]

c = Container()
c2 = copy.deepcopy(c)
c2.items.append(99)
print(c.items)   # [1, 2, 3] — unchanged
print(c2.items)  # [1, 2, 3, 99]

# Implementing __copy__ and __deepcopy__
class SmartCopy:
    def __init__(self, value):
        self.value = value
    def __copy__(self):
        print('shallow copy called')
        return SmartCopy(self.value)
    def __deepcopy__(self, memo):
        print('deep copy called')
        return SmartCopy(copy.deepcopy(self.value, memo))

sc = SmartCopy([1, 2])
copy.copy(sc)     # 'shallow copy called'
copy.deepcopy(sc) # 'deep copy called'
Built-in copy methods:

Lists:  .copy()  list()  [:]  copy.copy()
Dicts:  .copy()  dict()      copy.copy()
Sets:   .copy()  set()       copy.copy()

All create SHALLOW copies.
All are roughly equal in speed.
Choose for readability:
  lst.copy()     — clearest intent
  list(lst)      — most explicit
  lst[:]         — shortest
  copy.copy(lst) — generic

Tuples:
  Immutable, so "copy" is usually the same object.
  copy.copy(t) is t  — True
  copy.deepcopy(t)   — creates a new tuple only if
                        contents are mutable

Strings, ints, floats, booleans, None:
  Immutable. copy returns the same object.
  copy.copy(x) is x  — True

Custom classes:
  copy.copy(obj)   — shallow copy of __dict__
                     calls __copy__ if defined
  copy.deepcopy(obj) — deep copy of __dict__
                        calls __deepcopy__ if defined

Implementing __copy__ and __deepcopy__:

  class MyClass:
      def __init__(self, data):
          self.data = data
      
      def __copy__(self):
          # Return a new instance
          # Shallow: share nested objects
          return MyClass(self.data)
      
      def __deepcopy__(self, memo):
          # Return a new instance
          # Deep: recursively copy
          return MyClass(copy.deepcopy(self.data, memo))

  The memo dict is for tracking already-copied
  objects (handles circular references).
  Pass it to any deepcopy call inside __deepcopy__.

When to implement __deepcopy__:

  → Your object has resources that shouldn't be
    duplicated (file handles, sockets)
  → Your object has custom internal state that
    can't be copied by __dict__ alone
  → Your object is expensive to copy and you want
    to optimize
  → Your object has circular references that
    deepcopy needs help with

When NOT to implement:

  → Default behavior is correct (most cases)
  → You don't have nested mutable state
  → Performance isn't a concern

The default is usually fine.
Implement custom copy methods only when
you have a specific reason.

Special cases:

  ❌ File handles: deepcopy raises TypeError
     (can't copy an open file)
  
  ❌ Sockets: deepcopy raises TypeError
  
  ❌ Locks (threading.Lock): can't be deep-copied
  
  ❌ Modules: deepcopy returns the same module

  These are resource objects.
  Copying doesn't make sense — you'd get two
  handles to the same resource, with undefined
  behavior.
  
  If your object holds these, implement
  __deepcopy__ to control what gets copied.
  Or refactor to keep resources outside the
  copied structure.

The cleanest pattern:

  Keep mutable state separate from immutable
  state when possible.
  
  ❌ class Config:
         def __init__(self):
             self.file = open('config.txt')  # resource
             self.values = {}                 # state
  
  ✅ class Config:
         def __init__(self, values=None):
             self.values = values or {}
         # File opened per-operation, not stored
  
  Then deepcopy(config) works — it copies values
  (a dict, deep-copyable) and nothing else.
  
  The bug is often "I want to copy my object,"
  when the real answer is "my object shouldn't hold
  that resource."

Don't assume = copies in Python. This is the single most common copy bug, and it shows up in every Python codebase. a = b creates a new name for the same object. If you mutate b, a changes. If you mutate a, b changes. The bug is subtle because simple tests pass — you have to actually mutate the object to notice. And by then, the mutation is in production. The fix is one line: a = list(b) for a shallow copy, a = copy.deepcopy(b) for a deep copy. Use it whenever you're about to mutate a container you received as an argument or returned from a function.

⚠️ Common mistakes

The copy mistakes that cost real time.

1. Assuming = copies

It aliases. Fix: list(a) or copy.copy(a).

2. Shallow copy of nested data

Inner objects are shared. Fix: copy.deepcopy(a).

3. list(a) on a list of dicts

Dicts are shared. Fix: copy.deepcopy(a).

4. Deep copying when shallow is enough

Wasted performance. Fix: only deep copy if the structure requires it.

5. Deep copying an object with resources

File handles, locks — deepcopy raises. Fix: implement __deepcopy__ or refactor.

6. Deep copying circular structures without memo

Infinite recursion if you implement __deepcopy__ wrong. Fix: pass memo to nested deepcopies.

7. Modifying a dict while iterating its copy

Depends on the copy. Fix: list(d.keys()) or d.copy().

8. Expecting dict.copy() to deep-copy values

It's shallow. Fix: copy.deepcopy(d).

❓ Frequently asked

What is the difference between shallow and deep copy in Python?

A shallow copy creates a new container but shares the objects inside. A deep copy creates a new container AND recursively copies everything inside. Use copy.copy() for shallow, copy.deepcopy() for deep. For flat containers of immutable items, a shallow copy is enough. For nested containers or containers of mutable objects, you need a deep copy.

Does assignment (=) copy in Python?

No. Assignment creates a new reference to the same object, not a copy. a = [1, 2, 3]; b = a — a and b point to the same list. Modifying b modifies a. To copy, use copy.copy(a) or list(a) or a[:] for a shallow copy, or copy.deepcopy(a) for a deep copy.

What is the fastest way to copy a list?

Three ways, all creating shallow copies: (1) list(a) — calls the list constructor. (2) a[:] — full slice. (3) a.copy() — the list method. All are equivalent in behavior. For nested lists or lists of mutable objects, none of these are enough — use copy.deepcopy(a).

Why does modifying a copied list change the original?

Because you made a shallow copy, and the inner items are shared. Example: original = [[1, 2], [3, 4]]; copied = list(original). The outer list is new, but the inner lists are the same objects. copied[0].append(99) modifies the inner list in both. Fix: use copy.deepcopy(original).

Does copy.deepcopy handle circular references?

Yes. deepcopy tracks objects it has already copied and reuses the copies, so circular references (an object that references itself, directly or indirectly) are handled correctly. Example: a = []; a.append(a); copy.deepcopy(a) works.

What is copy.copy vs the copy method on lists?

They do the same thing — both create a shallow copy. list.copy() is a method on list. copy.copy(obj) is a function that works on any object with a __copy__ method (or falls back to a shallow copy for built-ins). Use list.copy() when you know it's a list — clearer. Use copy.copy() for generic objects.

🔗 Continue learning

🗺️ Python learning path
The full roadmap from install to first project — every concept in order

Next in this series:

↔️ Pass by reference or value?
Neither — it's call by sharing. And the mutable default argument trap
← All Python problems
Problem solved · Python