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:
Related posts
Four cost gates that decide your token bill (none of them is the price per token)
The Mathematics of Aspect Ratios: How Euclidean GCD and Modern CSS Eliminated the 56.25% Hack
What are your goals for the week? #204
CSS-Tricks, Version 20
