Coding tips

Python Assignment vs Shallow Copy vs Deep Copy (copy vs deepcopy): Differences, Nested Lists and When to Use Each (Tested Examples)

Assignment in Python never copies anything, a shallow copy duplicates only the outer container, and copy.deepcopy duplicates everything inside it too. Here is how each behaves with nested lists, dicts and classes, with real output from Python 3.13 and a speed test.

By 14 min read 4 views
Cover image for Python Assignment vs Shallow Copy vs Deep Copy (copy vs deepcopy): Differences, Nested Lists and When to Use Each (Tested Examples)

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.

MethodNew outer object?Nested objectsSpeed (my test)Use it when
b = a (assignment)No, same objectSharedInstantYou want a second name for the same data
a.copy(), a[:], list(a), dict(a), {**a}YesSharedAbout 1.6 microsecondsThe data is flat, or you only add, remove or replace top-level items
copy.copy(a)YesSharedAbout 1.7 microsecondsGeneric shallow copy of any object, including class instances
copy.deepcopy(a)YesNew copies, recursivelyAbout 2,376 microsecondsYou will change nested lists, dicts or objects independently
json.loads(json.dumps(a))YesNew copiesAbout 960 microsecondsOnly 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?

  1. Assignment when you want another name for the same data, for example passing a list into a function that should update it.
  2. Shallow copy when the data is flat (numbers, strings, tuples) or you only add, remove or replace top-level items.
  3. copy.deepcopy when the copy will change nested lists, dicts or objects and the original must not.
  4. copy.replace or dataclasses.replace when you work with immutable records and want a modified version.
  5. 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.

Share X LinkedIn WhatsApp Facebook
Was this helpful?

0%