DEV Community

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

Posted on Originally published at mfaysal.com

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))
Enter fullscreen mode Exit fullscreen mode
cart:   ['apple', 'bread', 'milk']
backup: ['apple', 'bread', 'milk']
same object? True True
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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]}
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode
grid: [[1, 0], [0, 0]]
s1:   [[1, 0], [0, 0], [9, 9]]
s2:   [[1, 0], [0, 0]]
rows shared? True True True
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
[['X', 0, 0], ['X', 0, 0], ['X', 0, 0]]
rows are the same object? True
[['X', 0, 0], [0, 0, 0], [0, 0, 0]]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
p1: Ana ['sword', 'shield']
p2: Ben ['sword', 'shield']
p3: Ana ['sword']
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
TypeError: cannot pickle '_thread.lock' object
c1.data: {'a': [1]} c2.data: {'a': [1, 2]} locks differ: True
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
tuple copy is same object? True True
tuple holding a list, deepcopy new? True | inner list new? True
str copy same object? True True
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
basket:   [12.0, 6.6] <- changed by the function
basket:   [10.0, 5.5]
with_tax: [12.0, 6.6]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
3.13.5
dataclasses.replace: Point(x=1, y=5)
copy.replace:        Point(x=10, y=2)
original unchanged:  Point(x=1, y=2)
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode
deepcopy: True
json round trip: TypeError: Object of type date is not JSON serializable
json turns tuples into lists: {'n': [1, 2]}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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.


Originally published on mfaysal.com.

I'm learning cybersecurity on TryHackMe — follow my progress at tryhackme.com/p/mfaysal.dev.

Top comments (0)