copy vs deepcopy in Python: Aliases, Shallow and Deep Copies
7 min readBytePatterns
copy vs deepcopy in Python: = makes an alias, a shallow copy makes a new container sharing its children, deepcopy copies all of it and keeps cycles intact.
copy vs deepcopy in Python in one line: copy.copy builds a new outer container and fills it with references to the same inner objects, while copy.deepcopy builds a new container and, recursively, new copies of everything inside it, using a memo dictionary so that shared objects stay shared and cycles do not loop forever. Plain assignment is neither: b = a copies nothing and gives the same object a second name. Which one you need depends on whether the children are allowed to be shared. The behaviour below follows the copy module documentation, as of October 2026, and runs on Python 3.9.
| Operation | New outer object? | New inner objects? |
|---|---|---|
b = a | no, an alias | no |
copy.copy(a), a[:], list(a), d.copy() | yes | no, children are shared |
copy.deepcopy(a) | yes | yes, once each; sharing and cycles preserved |
The problem it solves
You want to change a copy without changing the original: a game board for a "what if" move, a config tweaked per request, a test fixture reused across cases. With flat data any copy works. With nested data, a shallow copy looks independent until you modify something one level down and the original changes too, which is one of the more confusing bugs a Python program can have, because the line that caused it never mentions the original.
The intuition
The documentation starts from a sentence worth remembering: assignment statements do not copy objects, they create bindings between a target and an object.
- Alias.
b = abinds a second name. Every change through either name is visible through both, because there is one object. - Shallow copy. A new compound object is built and filled with references to the objects found in the original. Rebinding a slot in the copy, such as
copy[0] = xorcopy["key"] = 5, leaves the original alone. Mutating a child, such ascopy[0].append(x), does not, because that child is the same object in both.list(x),x[:],x.copy(),dict.copy()andcopy.copy(x)are all shallow. - Deep copy. A new compound object is built and, recursively, copies of the children are inserted. Two problems follow, and the documentation names both: a recursive structure would loop forever, and an object referenced twice would be copied twice.
deepcopysolves both with a memo dictionary fromid(original)to its copy. For lists, dicts and class instances, it registers the new object in the memo before descending into its children; anything that leads back to an original already in the memo gets the existing copy instead of a new one.
Immutable objects need no copying, so copy.copy of a tuple returns the same tuple, and deepcopy of a tuple that holds only immutable values returns it unchanged as well. Classes can control both operations with __copy__ and __deepcopy__.
When to use which
- You only read the data: no copy. Pass the object.
- Flat container of immutable values (numbers, strings, tuples of them): a shallow copy is a complete copy; use
list(x),x[:]ord.copy(). - Nested mutable data you will modify at depth:
copy.deepcopy, or build the copy explicitly when you know the shape, which is faster. - Large graphs or long linked lists: an explicit iterative copy with your own old-to-new map;
deepcopyrecurses and can hit the recursion limit. - Something should stay shared, such as a cache or a connection:
__deepcopy__, or copy explicitly and skip it.
Watch it run
The lesson's animation copies three nodes, each with a next pointer and a random pointer that can aim anywhere: b's aims backwards, a's forwards. Copying in one pass fails because a random target may not exist yet, so pass one makes a bare copy of every node, value only, and records old to new in a table. Once every node has a twin, pass two wires each copy's next and random pointer through that table, two lookups and never a search. Finally the table is cut away and the copy stands alone with the same shape and no shared nodes. That table is deepcopy's memo: the same mapping from each original to its one copy, built as it goes instead of in a separate pass.
Copy a List With Random Links
Step 1 of 9
Each node points at the next one and at any node at all. b's random link aims backwards; a's aims forwards.
The same interactive animation as the lesson — step through it with the controls.
The code
Alias, shallow and deep on a nested list, the five spellings of a shallow copy, and the two traps that are really aliasing in disguise:
import copy
grid = [[0, 0], [0, 0]]
alias = grid # no copy at all: a second name
shallow = copy.copy(grid) # new outer list, the same inner lists
deep = copy.deepcopy(grid) # new outer list, new inner lists
grid[0][0] = 9 # change a child
grid.append([7, 7]) # change the container
print(alias) # [[9, 0], [0, 0], [7, 7]]
print(shallow) # [[9, 0], [0, 0]]
print(deep) # [[0, 0], [0, 0]]
print(shallow is grid, shallow[0] is grid[0], deep[0] is grid[0]) # False True False
ways = [list(grid), grid[:], grid.copy(), copy.copy(grid), [*grid]]
print(all(w is not grid and w[0] is grid[0] for w in ways)) # True <- all shallow
config = {"retries": 3, "hosts": ["a"]}
mine = config.copy()
mine["retries"] = 5 # rebinding a key: only the copy changes
mine["hosts"].append("b") # mutating a shared child: both change
print(config) # {'retries': 3, 'hosts': ['a', 'b']}
rows = [[0] * 2] * 2 # one inner list, referenced twice
rows[0][0] = 1
print(rows) # [[1, 0], [1, 0]]
def add(item, bucket=[]): # the default list is built once, at def time
bucket.append(item)
return bucket
print(add(1), add(2)) # [1, 2] [1, 2]
The last two are the same bug as a shallow copy. [[0] * 2] * 2 repeats a reference to one inner list. A default value is evaluated once, when the function is defined, so every call that omits bucket appends to the same list; the fix is bucket=None and a new list inside. Next, a structure that contains itself, and the lesson's linked list with random pointers, copied by deepcopy and by the lesson's two passes:
import copy
loop = [1]
loop.append(loop) # a list that contains itself
twin = copy.deepcopy(loop)
print(twin[1] is twin, twin[1] is loop) # True False
class Node: # the lesson's node: next plus a random link
def __init__(self, v):
self.val, self.next, self.rand = v, None, None
a, b, c = Node("a"), Node("b"), Node("c")
a.next, b.next = b, c
a.rand, b.rand, c.rand = c, a, c # forwards, backwards, itself
memo = {}
a2 = copy.deepcopy(a, memo)
b2, c2 = a2.next, a2.next.next
print(a2.rand is c2, b2.rand is a2, c2.rand is c2) # True True True
print(a2 is a, c2 is c) # False False
print(memo[id(a)] is a2, memo[id(c)] is c2) # True True <- old id -> new object
def manual(head): # the lesson's two passes, spelled out
clone = {None: None}
n = head
while n:
clone[n] = Node(n.val)
n = n.next
for old, new in clone.items():
if old is not None:
new.next, new.rand = clone[old.next], clone[old.rand]
return clone[head]
m = manual(a)
print(m.rand is m.next.next, m.next.rand is m) # True True
deepcopy never saw "a linked list"; it followed attributes, found c twice, through b.next and through a.rand, and the memo made both references point at one copy. Last, a seeded check of that promise on random structures where several slots share a child, plus the cost:
import copy, random, timeit
def same_pattern(xs, ys): # xs[i] is xs[j] exactly when ys[i] is ys[j]
return all((xs[i] is xs[j]) == (ys[i] is ys[j])
for i in range(len(xs)) for j in range(len(xs)))
rng = random.Random(3)
ok = True
for _ in range(1000):
children = [[rng.randint(0, 9)] for _ in range(rng.randint(1, 5))]
parent = [rng.choice(children) for _ in range(rng.randint(1, 8))] # repeats share a child
deep, shallow = copy.deepcopy(parent), copy.copy(parent)
ok &= deep == parent and same_pattern(parent, deep)
ok &= not any(d is p for d in deep for p in parent) # nothing shared
ok &= all(s is p for s, p in zip(shallow, parent)) # everything shared
print(ok) # True
shared = [0]
pair = [shared, shared]
dup = copy.deepcopy(pair)
dup[0].append(1)
print(dup, dup[0] is dup[1]) # [[0, 1], [0, 1]] True <- still one child
data = [[i, i + 1] for i in range(10_000)]
t_shallow = min(timeit.repeat(lambda: copy.copy(data), number=20, repeat=3))
t_deep = min(timeit.repeat(lambda: copy.deepcopy(data), number=20, repeat=3))
print("deepcopy over 10x slower:", t_deep > 10 * t_shallow) # deepcopy over 10x slower: True
# on one machine, roughly; it was far more than 10x
On one machine, roughly, the deep copy of 10,000 small lists was a few hundred times slower than the shallow one: it visits every object, looks each one up in the memo and dispatches on its type.
The complexity
- Alias:
O(1), nothing is created. - Shallow copy:
O(n)in the length of the outer container, references only. - Deep copy:
O(V + E)over every object reachable and every reference between them, with a memo entry per copied object, soO(V)extra space. The constant is large because each step is a Python-level type dispatch. - Recursion depth:
deepcopyrecurses as deep as the structure, so a long chain such as a linked list can raiseRecursionError.
Where it goes wrong
- Shallow copy of nested data.
new = old[:]and thennew[i].append(...)changesold. Deep copy, or copy the level you will modify. [[0] * m] * nfor a grid. All rows are one list. Write[[0] * m for _ in range(n)].- Mutable default arguments. The default is shared across calls. Default to
None. - Deep-copying what should be shared. A deep copy of an object holding a lock, a file or a large read-only lookup table either fails or wastes memory. Define
__deepcopy__, or copy by hand. - Deep copy in a hot loop. It is the general tool and costs like one; a hand-written copy for a known shape is far cheaper.
When it shows up in interviews
Two ways. As a Python question, "what is the difference between a shallow and a deep copy?", often with a nested-list snippet to predict. And inside algorithm problems: backtracking that appends path instead of path[:], or a grid built with *. The clone-a-graph family, including the random-pointer list, is the same memo idea written by hand; the two classic solutions are compared in copy list with random pointer: hash map vs interleaving, and the two-pass method is the Copy a List With Random Links lesson.
How to say it in an interview
"Assignment never copies; it binds another name. A shallow copy, like list(x), x[:] or copy.copy, makes a new container whose slots point at the same children, so mutating a child shows through both. deepcopy copies recursively and keeps a memo from each original's id to its copy, so cycles terminate and an object referenced twice is copied once and still referenced twice. That memo is exactly the old-to-new map I would write to clone a graph or a list with random pointers, and for long structures I would write it iteratively, because deepcopy recurses."
Frequently asked questions
What is the difference between copy and deepcopy in Python?
copy.copy creates a new outer object whose contents are references to the same inner objects. copy.deepcopy also copies those inner objects, recursively, using a memo so shared references and cycles are preserved. With nested mutable data, only the deep copy is fully independent of the original.
Does list slicing create a deep copy?
No. x[:], like list(x) and x.copy(), is a shallow copy: a new list holding the same element objects. For a list of numbers or strings that is enough, because they are immutable; for a list of lists, changing an inner list through the copy changes the original too.
How does deepcopy handle circular references?
It keeps a memo dictionary mapping the id of each original object to its copy, and registers each new object before copying its children. When the walk reaches an object already in the memo, such as a list that contains itself, it reuses that copy instead of recursing again.
Sources
- copy — Shallow and deep copy operations — Python Documentation
- Built-in Types: mutable sequence operations,
s.copy()anddict.copy()— Python Documentation - Compound statements: function definitions and default parameter values — Python Documentation
- Programming FAQ: why are default values shared between objects? — Python Documentation