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.

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])          # False
assignment            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]]     <- independent

A 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 time
import 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 it

When 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 case

If 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"])       # light

Deep 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)               # False

deepcopy 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 preserved

Cost

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 purpose

Define __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() or list(x) is deep.
  • Using deepcopy everywhere 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

  1. Show a case where list(original) is enough and one where it is not.
  2. Deep copy a structure containing a cycle and prove the cycle is preserved.
  3. Write a class whose deep copy deliberately shares one attribute.
  4. Rewrite a function that deep copies and mutates so that it builds a new object instead.
  5. 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.