In Python, b = a does not copy anything: it gives the same object a second name, so a change through one name shows up in the other. A shallow copy (a.copy(), a[:], list(a), dict(a) or copy.copy(a)) creates a new outer container, but the lists and dicts inside it are still shared. copy.deepcopy(a) copies the container and everything inside it, recursively, so the result is fully independent.
If you have ever changed a "copy" of a list and watched the original change too, one of these three behaviours is the reason. The copy module documentation defines shallow and deep copies in two sentences. Seeing it happen is what makes it stick.
Every example below ran on my machine with Python 3.13.5, and the copy.replace() example also ran on 3.11, 3.12 and 3.14. The output blocks are pasted straight from those runs, nothing retyped.
In this guide:
- Assignment: two names, one object
- Shallow copy: a new box with the same contents
- Shallow copies of lists: slicing, list() and copy()
- Deep copy: copy.deepcopy copies everything inside
- Assignment vs shallow copy vs deep copy compared
- The [[0] * 3] * 3 trap
- Copying class instances
- When deepcopy fails, and how __deepcopy__ fixes it
- Shared references and self-referencing lists
- Tuples, strings and other immutables
- The bug you get without copying: functions that change their arguments
- copy.replace() in Python 3.13: copy with changes
- deepcopy vs a JSON round trip
- How fast is each one?
- Which one should you use?
- Frequently asked questions
Assignment: two names, one object
Start with the case that catches beginners most often:
cart = ["apple", "bread"]
backup = cart # assignment: a second name, same list
backup.append("milk")
print("cart: ", cart)
print("backup:", backup)
print("same object?", backup is cart, id(backup) == id(cart))
cart: ['apple', 'bread', 'milk']
backup: ['apple', 'bread', 'milk']
same object? True True
backup was never a backup. Assignment binds a name to an existing object, and is plus id() both confirm that the two names point to the same list. The Python data model describes this: every object has an identity, and names are just references to it.
This is not a bug. It is how Python avoids copying large data every time you pass it around. You only need to care when the object is mutable, like a list, dict, set or most class instances.
Shallow copy: a new box with the same contents
A shallow copy creates a new outer object and fills it with references to the same inner objects. For dictionaries there are four common ways to do it, and my test shows they all behave the same:
import copy
scores = {"maths": [70, 80], "art": [90]}
a = scores.copy() # dict method
b = dict(scores) # constructor
c = {**scores} # unpacking
d = copy.copy(scores) # copy module
for name, obj in [("copy()", a), ("dict()", b), ("{**}", c), ("copy.copy", d)]:
print(f"{name:10} new dict? {obj is not scores} inner list shared? {obj['maths'] is scores['maths']}")
a["art"] = [100] # replacing a key: only the copy changes
a["maths"].append(99) # mutating a shared inner list: both change
print("original:", scores)
print("copy: ", a)
copy() new dict? True inner list shared? True
dict() new dict? True inner list shared? True
{**} new dict? True inner list shared? True
copy.copy new dict? True inner list shared? True
original: {'maths': [70, 80, 99], 'art': [90]}
copy: {'maths': [70, 80, 99], 'art': [100]}
Two things happened. Replacing a["art"] with a new list changed only the copy, because the copy has its own outer dict. But a["maths"].append(99) changed both, because "maths" in both dicts still points to the same inner list. The built-in types reference lists dict.copy() and list.copy() as shallow copies for exactly this reason.
Shallow copies of lists: slicing, list() and copy()
Lists behave the same way. grid[:], list(grid) and grid.copy() all produce a new outer list that shares its rows:
grid = [[0, 0], [0, 0]]
s1 = grid[:]
s2 = list(grid)
s3 = grid.copy()
s1[0][0] = 1 # changes the shared row
s1.append([9, 9]) # changes only s1's outer list
print("grid:", grid)
print("s1: ", s1)
print("s2: ", s2)
print("rows shared?", s1[1] is grid[1], s2[1] is grid[1], s3[1] is grid[1])
grid: [[1, 0], [0, 0]]
s1: [[1, 0], [0, 0], [9, 9]]
s2: [[1, 0], [0, 0]]
rows shared? True True True
Appending [9, 9] only affected s1, but writing to s1[0][0] changed the original grid and every other copy, because they all hold the same row objects. If your list holds only numbers, strings or tuples, a shallow copy is all you need, since those values cannot change in place. My guide to list vs tuple vs set vs dict covers which types are mutable.
Deep copy: copy.deepcopy copies everything inside
copy.deepcopy() walks through the object and copies every mutable thing it finds:
import copy
config = {"db": {"host": "localhost", "ports": [5432]}, "debug": False}
deep = copy.deepcopy(config)
print("inner dict shared? ", deep["db"] is config["db"])
print("inner list shared? ", deep["db"]["ports"] is config["db"]["ports"])
print("host string shared? ", deep["db"]["host"] is config["db"]["host"])
deep["db"]["ports"].append(6543)
deep["db"]["host"] = "prod.internal"
print("original:", config)
print("deep: ", deep)
inner dict shared? False
inner list shared? False
host string shared? True
original: {'db': {'host': 'localhost', 'ports': [5432]}, 'debug': False}
deep: {'db': {'host': 'prod.internal', 'ports': [5432, 6543]}, 'debug': False}
The inner dict and inner list are new objects, so changing the copy left the original config alone. Notice the third line, though: the host string was not copied. Strings, numbers, booleans and other immutable values are safe to share, so deepcopy simply reuses them. That is why a deep copy is not twice the memory of the original.
Assignment vs shallow copy vs deep copy compared
Here is the whole difference in one table. The speed column comes from the timing test further down, copying a list of 1,000 small dicts.
| Method | New outer object? | Nested objects | Speed (my test) | Use it when |
|---|---|---|---|---|
b = a (assignment) |
No, same object | Shared | Instant | You want a second name for the same data |
a.copy(), a[:], list(a), dict(a), {**a}
|
Yes | Shared | About 1.6 microseconds | The data is flat, or you only add, remove or replace top-level items |
copy.copy(a) |
Yes | Shared | About 1.7 microseconds | Generic shallow copy of any object, including class instances |
copy.deepcopy(a) |
Yes | New copies, recursively | About 2,376 microseconds | You will change nested lists, dicts or objects independently |
json.loads(json.dumps(a)) |
Yes | New copies | About 960 microseconds | Only for plain JSON data; loses tuples, sets and dates |
The short rule: if you are about to change something inside the copy, not just the copy itself, you need deepcopy. And if you want to sanity-check data while you debug, such as formatting JSON or comparing two outputs, my free browser tools work right in your browser.
The [[0] * 3] * 3 trap
This is the shallow-copy bug in disguise, and it shows up in grid, board and matrix code all the time:
board = [[0] * 3] * 3 # looks like 3 rows, really 1 row three times
board[0][0] = "X"
print(board)
print("rows are the same object?", board[0] is board[1] is board[2])
board = [[0] * 3 for _ in range(3)]
board[0][0] = "X"
print(board)
[['X', 0, 0], ['X', 0, 0], ['X', 0, 0]]
rows are the same object? True
[['X', 0, 0], [0, 0, 0], [0, 0, 0]]
Multiplying a list repeats references, so [[0] * 3] * 3 holds the same row three times. Setting one cell put an X in every row. The list comprehension builds a new row on each pass, which is the fix the official Python programming FAQ recommends for multidimensional lists.
Copying class instances
The copy module works on your own classes too, and the same shallow-versus-deep rule applies to their attributes:
import copy
class Player:
def __init__(self, name, items):
self.name = name
self.items = items
p1 = Player("Ana", ["sword"])
p2 = copy.copy(p1)
p3 = copy.deepcopy(p1)
p2.name = "Ben"
p2.items.append("shield")
print("p1:", p1.name, p1.items)
print("p2:", p2.name, p2.items)
print("p3:", p3.name, p3.items)
p1: Ana ['sword', 'shield']
p2: Ben ['sword', 'shield']
p3: Ana ['sword']
copy.copy(p1) made a new Player, so renaming p2 did not rename p1. But the items list was shared, so Ben's shield also appeared in Ana's inventory. The deep copy p3 was taken before the change and kept its own list. This is a common source of "why did every enemy get the same power-up?" bugs in games and simulations.
When deepcopy fails, and how __deepcopy__ fixes it
deepcopy cannot copy everything. Locks, open files, sockets and database connections hold operating-system resources, and copying them makes no sense:
import copy, threading
class Cache:
def __init__(self):
self.data = {"a": [1]}
self.lock = threading.Lock()
try:
copy.deepcopy(Cache())
except TypeError as e:
print("TypeError:", e)
class SafeCache(Cache):
def __deepcopy__(self, memo):
new = SafeCache() # fresh lock
new.data = copy.deepcopy(self.data, memo)
return new
c1 = SafeCache()
c2 = copy.deepcopy(c1)
c2.data["a"].append(2)
print("c1.data:", c1.data, "c2.data:", c2.data, "locks differ:", c1.lock is not c2.lock)
TypeError: cannot pickle '_thread.lock' object
c1.data: {'a': [1]} c2.data: {'a': [1, 2]} locks differ: True
The error mentions pickle because deepcopy falls back on the same object-reduction protocol that the pickle module uses. The fix is to define __deepcopy__ on your class: create a fresh lock, deep-copy only the data, and pass the memo dictionary along so shared references are handled correctly. After that the copy worked and each object got its own lock.
Shared references and self-referencing lists
Two details make deepcopy smarter than a simple recursive loop:
import copy
shared = [1, 2]
pair = [shared, shared]
d = copy.deepcopy(pair)
print("inside the copy, still one shared list?", d[0] is d[1])
print("but separate from the original?", d[0] is not shared)
loop = [1]
loop.append(loop) # a list that contains itself
print("loop:", loop)
dl = copy.deepcopy(loop)
print("deep copy of loop:", dl, "| points to itself?", dl[1] is dl)
inside the copy, still one shared list? True
but separate from the original? True
loop: [1, [...]]
deep copy of loop: [1, [...]] | points to itself? True
First, if the same list appears twice in the original, the deep copy also has one list appearing twice, not two separate lists. Second, a list that contains itself does not cause infinite recursion. Both come from the memo dictionary, which remembers every object already copied during the current call.
Tuples, strings and other immutables
For immutable objects, copying is mostly pointless, and Python knows it:
import copy
t = (1, 2, "three")
print("tuple copy is same object?", copy.copy(t) is t, copy.deepcopy(t) is t)
t2 = (1, [2, 3])
dt2 = copy.deepcopy(t2)
print("tuple holding a list, deepcopy new?", dt2 is not t2, "| inner list new?", dt2[1] is not t2[1])
s = "hello"
print("str copy same object?", copy.copy(s) is s, copy.deepcopy(s) is s)
tuple copy is same object? True True
tuple holding a list, deepcopy new? True | inner list new? True
str copy same object? True True
copy.copy and copy.deepcopy returned the very same tuple and string. The exception is a tuple that holds something mutable, like a list. A deep copy then has to build a new tuple, because the list inside it needs to be copied.
The bug you get without copying: functions that change their arguments
Python passes references into functions, so a function that modifies a list in place also changes the caller's list:
def add_tax(prices):
for i in range(len(prices)):
prices[i] = round(prices[i] * 1.2, 2)
return prices
basket = [10.0, 5.5]
with_tax = add_tax(basket)
print("basket: ", basket, "<- changed by the function")
def add_tax_safe(prices):
return [round(p * 1.2, 2) for p in prices]
basket = [10.0, 5.5]
with_tax = add_tax_safe(basket)
print("basket: ", basket)
print("with_tax:", with_tax)
basket: [12.0, 6.6] <- changed by the function
basket: [10.0, 5.5]
with_tax: [12.0, 6.6]
You could fix the first version with prices = prices.copy() at the top, but returning a new list with a comprehension is cleaner and needs no copy at all. My list comprehension examples show more patterns like this. As a habit, either change the argument in place and return None, like list.sort() does, or leave it untouched and return a new object, like sorted(). Mixing the two causes surprises.
copy.replace() in Python 3.13: copy with changes
Python 3.13 added copy.replace(), which makes a copy of an object with some fields changed. It works on dataclasses, named tuples and any class with a __replace__ method, and it is perfect for frozen, immutable objects:
import copy, sys
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print(sys.version.split()[0])
print("dataclasses.replace:", replace(p, y=5))
print("copy.replace: ", copy.replace(p, x=10))
print("original unchanged: ", p)
3.13.5
dataclasses.replace: Point(x=1, y=5)
copy.replace: Point(x=10, y=2)
original unchanged: Point(x=1, y=2)
The What's New in Python 3.13 page lists it under the copy module. On older versions it simply does not exist. Here is the same file on Python 3.11:
3.11.17
dataclasses.replace: Point(x=1, y=5)
Traceback (most recent call last):
File "e10_replace.py", line 12, in <module>
print("copy.replace: ", copy.replace(p, x=10))
^^^^^^^^^^^^
AttributeError: module 'copy' has no attribute 'replace'
[exit 1]
Python 3.12.16 failed the same way, and 3.14.8 matched 3.13. On 3.12 or older, use dataclasses.replace(), which did the same job in every version I tested.
deepcopy vs a JSON round trip
Some tutorials suggest json.loads(json.dumps(data)) as a "faster deep copy". It does produce independent nested objects, but it only understands JSON types:
import copy, json
from datetime import date
data = {"when": date(2026, 11, 9), "tags": {"a", "b"}, "n": (1, 2)}
print("deepcopy:", copy.deepcopy(data) == data)
try:
json.loads(json.dumps(data))
except TypeError as e:
print("json round trip: TypeError:", e)
simple = {"n": (1, 2)}
print("json turns tuples into lists:", json.loads(json.dumps(simple)))
deepcopy: True
json round trip: TypeError: Object of type date is not JSON serializable
json turns tuples into lists: {'n': [1, 2]}
A date crashed it, and the tuple silently came back as a list. Use JSON for saving data, as in my guide to writing JSON to a file, and use deepcopy for copying it.
How fast is each one?
I timed each approach on the same data with the timeit module, taking the best of five rounds:
import copy, json, timeit, sys, platform
data = [{"id": i, "tags": ["a", "b"], "meta": {"score": i * 1.5}} for i in range(1000)]
tests = {
"assignment (b = a)": lambda: data,
"list.copy() (shallow)": lambda: data.copy(),
"copy.copy (shallow)": lambda: copy.copy(data),
"json loads/dumps": lambda: json.loads(json.dumps(data)),
"copy.deepcopy": lambda: copy.deepcopy(data),
}
print(f"Python {sys.version.split()[0]}, {platform.machine()}, list of 1,000 small dicts, best of 5 x 200 runs")
for name, fn in tests.items():
best = min(timeit.repeat(fn, number=200, repeat=5)) / 200
print(f"{name:24} {best * 1e6:10.1f} microseconds per copy")
Python 3.13.5, x86_64, list of 1,000 small dicts, best of 5 x 200 runs
assignment (b = a) 0.0 microseconds per copy
list.copy() (shallow) 1.6 microseconds per copy
copy.copy (shallow) 1.7 microseconds per copy
json loads/dumps 947.0 microseconds per copy
copy.deepcopy 2420.0 microseconds per copy
The exact numbers depend on the machine, but the pattern is what matters. A shallow copy of this list took about 1.6 microseconds. A deep copy took about 2,376, more than a thousand times longer, because it visits all 1,000 dicts and their inner lists. The JSON round trip was faster here, but it fails on the types shown above. Deep-copy when you need independence, not by default.
Which one should you use?
- Assignment when you want another name for the same data, for example passing a list into a function that should update it.
- Shallow copy when the data is flat (numbers, strings, tuples) or you only add, remove or replace top-level items.
-
copy.deepcopywhen the copy will change nested lists, dicts or objects and the original must not. -
copy.replaceordataclasses.replacewhen you work with immutable records and want a modified version. -
A custom
__deepcopy__when your class holds locks, files or connections.
If you also write JavaScript, the same three levels exist there, with structuredClone() as the deep copy. I compared them in how to deep copy an object in JavaScript.
Frequently asked questions
What is the difference between copy and deepcopy in Python?
copy.copy creates a new outer object but keeps references to the same nested objects. copy.deepcopy creates a new outer object and recursively copies every mutable object inside it, so changes to nested data do not affect the original.
Does b = a copy a list in Python?
No. Assignment makes b another name for the same list. In my test, b is a returned True and appending through b changed a as well.
Is list.copy() a deep copy?
No. list.copy(), slicing with [:] and list() are all shallow. The new list shares its inner lists and dicts with the original, so changing a nested item affects both.
Why does changing one row change every row in my 2D list?
You probably built it with [[0] * 3] * 3, which repeats one row object three times. Build it with a list comprehension, [[0] * 3 for _ in range(3)], so each row is a separate list.
Why does deepcopy fail with cannot pickle _thread.lock object?
deepcopy reached an object such as a lock, file or connection that cannot be copied. Define __deepcopy__ on the class to create a fresh resource and deep-copy only the data.
Is deepcopy slow?
It is much slower than a shallow copy. In my test on a list of 1,000 small dicts, a shallow copy took about 1.6 microseconds and deepcopy about 2,376 microseconds. Use it when you need independent nested data.
Can I use json.loads(json.dumps(x)) instead of deepcopy?
Only for plain JSON data. In my test it raised TypeError for a date and turned a tuple into a list. deepcopy handled both correctly.
Last checked: 9 November 2026. Figures, settings and guidance can change, so confirm anything important with the sources linked above.
Originally published on mfaysal.com.
I'm learning cybersecurity on TryHackMe — follow my progress at tryhackme.com/p/mfaysal.dev.
Top comments (0)