Shallow and Deep Copy
Assignment shares, a shallow copy duplicates one level, and a deep copy duplicates everything. Choosing the wrong one is one of the most common sources of quiet bugs.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
The three levels
import copy
original = [[1, 2], [3, 4]]
alias = original # no copy at all
shallow = copy.copy(original) # a new outer list
deep = copy.deepcopy(original) # new everything
print(original is alias) # True
print(original is shallow) # False
print(original[0] is shallow[0]) # True <- the inner lists are shared
print(original[0] is deep[0]) # Falseassignment shallow copy deep copy
original ─┐ original ─► [ • , • ] original ─► [ • , • ]
├─► [ • , • ] \ \ | |
alias ────┘ | | \ \ v v
v v v v [1,2] [3,4]
[1,2] [3,4] [1,2] [3,4]
^ ^ deep ─► [ • , • ]
\ \ | |
shallow ─► [ • , • ] v v
[1,2] [3,4]
(new objects)Seeing the difference
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
original.append([5, 6]) # changes the OUTER list
print(shallow) # [[1, 2], [3, 4]] - unaffected
print(deep) # [[1, 2], [3, 4]] - unaffected
original[0].append(99) # changes an INNER list
print(shallow) # [[1, 2, 99], [3, 4]] <- shared
print(deep) # [[1, 2], [3, 4]] <- independentA shallow copy protects you from changes to the outer container and not from changes to what it contains. That distinction is the whole subject.
Ways to make a shallow copy
import copy
original = [[1], [2]]
a = copy.copy(original)
b = original.copy()
c = original[:]
d = list(original)
e = [*original]
for candidate in [a, b, c, d, e]:
print(candidate is original, candidate[0] is original[0])
# False True - a new outer list, the same inner objects, every timeimport copy
d = {"a": [1], "b": [2]}
print(d.copy(), dict(d), {**d}) # all shallow
s = {1, 2}
print(s.copy(), set(s)) # shallow
t = (1, [2])
print(copy.copy(t) is t) # True - copying an immutable returns itWhen shallow is enough
import copy
# Every element is immutable, so sharing them is harmless
numbers = [1, 2, 3]
names = ["Meera", "Arun"]
points = [(0, 0), (1, 1)]
for original in [numbers, names, points]:
duplicate = copy.copy(original)
duplicate.append("new")
print(original) # unchanged, in every caseIf nothing inside the container can be modified, a shallow copy is a full copy for all practical purposes - and it is much faster.
When you need deep
import copy
template = {
"name": "default",
"settings": {"theme": "light", "size": 12},
"tags": ["a", "b"],
}
# Wrong: every "copy" shares the settings dictionary
users = [dict(template) for _ in range(3)]
users[0]["settings"]["theme"] = "dark"
print(users[1]["settings"]["theme"]) # dark - all three changed
# Right
users = [copy.deepcopy(template) for _ in range(3)]
users[0]["settings"]["theme"] = "dark"
print(users[1]["settings"]["theme"]) # lightDeep copy handles cycles
import copy
a = [1, 2]
a.append(a) # a list containing itself
print(a[2] is a) # True
b = copy.deepcopy(a)
print(b[2] is b) # True - the structure is preserved
print(b is a) # False
print(b[2] is a) # Falsedeepcopy keeps a memo of everything it has already copied, so a cycle does not cause infinite recursion, and an object referenced twice is copied once.
import copy
shared = [1, 2]
original = {"first": shared, "second": shared}
d = copy.deepcopy(original)
print(d["first"] is d["second"]) # True - the sharing is preservedCost
import copy
import time
data = [[i] * 10 for i in range(10_000)]
start = time.perf_counter()
copy.copy(data)
print(f"shallow: {time.perf_counter() - start:.4f}s")
start = time.perf_counter()
copy.deepcopy(data)
print(f"deep: {time.perf_counter() - start:.4f}s")deepcopy visits every object, tracks what it has seen, and allocates a new object for each one. On a large structure it is orders of magnitude slower than a shallow copy. Reach for it when you need it, not by default.
Controlling how a class is copied
import copy
class Document:
def __init__(self, title, tags, connection=None):
self.title = title
self.tags = tags
self.connection = connection # something that must not be copied
def __copy__(self):
print(" custom shallow copy")
return Document(self.title, self.tags, self.connection)
def __deepcopy__(self, memo):
print(" custom deep copy")
return Document(
copy.deepcopy(self.title, memo),
copy.deepcopy(self.tags, memo),
self.connection, # deliberately shared, not copied
)
def __repr__(self):
return f"Document({self.title!r}, {self.tags})"
d = Document("Report", ["draft"], connection="db-handle")
s = copy.copy(d)
deep = copy.deepcopy(d)
deep.tags.append("final")
print(d.tags, deep.tags) # independent
print(deep.connection is d.connection) # True - shared on purposeDefine __deepcopy__ when an object holds something that cannot or should not be duplicated: an open file, a database connection, a lock, a socket.
Avoiding copies altogether
import copy
# Copying to avoid mutating the caller's data
def add_tag_copy(record, tag):
result = copy.deepcopy(record)
result["tags"].append(tag)
return result
# Better: build a new structure instead
def add_tag(record, tag):
return {**record, "tags": [*record["tags"], tag]}
record = {"name": "note", "tags": ["draft"]}
updated = add_tag(record, "final")
print(record["tags"]) # ['draft'] - untouched
print(updated["tags"]) # ['draft', 'final']from dataclasses import dataclass, replace, field
@dataclass(frozen=True)
class Settings:
theme: str = "light"
size: int = 12
base = Settings()
dark = replace(base, theme="dark") # a new object, no copying needed
print(base, dark)Immutable data removes the question entirely. If nothing can be modified, there is never a reason to copy it defensively.
A worked example
import copy
class GameState:
def __init__(self, board, players, history=None):
self.board = board
self.players = players
self.history = history or []
def move(self, row, column, symbol):
"""Return a NEW state rather than modifying this one."""
new_board = copy.deepcopy(self.board)
if new_board[row][column] != " ":
raise ValueError("that square is occupied")
new_board[row][column] = symbol
return GameState(
new_board,
self.players,
self.history + [(row, column, symbol)],
)
def show(self):
for row in self.board:
print("|" + "|".join(row) + "|")
print(f"moves: {len(self.history)}")
start = GameState([[" "] * 3 for _ in range(3)], ["X", "O"])
after_one = start.move(1, 1, "X")
after_two = after_one.move(0, 0, "O")
print("start:")
start.show()
print("after two moves:")
after_two.show()
# The original is intact, so undo is simply keeping the earlier state
print("undo to:", after_one.history)Because each move returns a new state, undo costs nothing and the history is genuinely a history. That is worth the cost of a deep copy per move on a three by three board; on a very large structure you would store the moves instead and replay them.
Common mistakes
- Assuming
.copy()orlist(x)is deep. - Using
deepcopyeverywhere out of caution, making the program slow. - Copying an object holding a file handle or a connection.
- Building several dictionaries from one template with
dict(template). - Deep copying inside a loop when one copy outside would do.
- Forgetting that copying an immutable object simply returns the same object.
Best practices
- Ask whether the contents are mutable. If not, shallow is enough.
- Prefer building new structures to copying and mutating.
- Use frozen dataclasses and tuples so copies are unnecessary.
- Define
__deepcopy__for classes holding resources. - Measure before deep copying inside a hot loop.
Practice
- Show a case where
list(original)is enough and one where it is not. - Deep copy a structure containing a cycle and prove the cycle is preserved.
- Write a class whose deep copy deliberately shares one attribute.
- Rewrite a function that deep copies and mutates so that it builds a new object instead.
- Time shallow and deep copies of a list of 50 000 small lists.
Conclusion
Assignment shares, copy.copy duplicates one level, copy.deepcopy duplicates everything and handles cycles. Choose by asking whether the contents can change - and prefer immutable data, which removes the choice.