Copying Objects

Assignment never copies an object in Python — it just creates another name for the same one; copying is a deliberate, separate operation with shallow and deep variants that behave very differently on nested data.

Because Python's object model is reference-based, a = b never duplicates b's value — it makes a a second name for the exact same object. Genuinely copying an object — producing an independent object with equal but separately-stored contents — requires an explicit operation, and Python distinguishes between a shallow copy (which duplicates the outer container but shares its contents) and a deep copy (which recursively duplicates everything reachable from it).

Assignment Is Not Copying

This distinction is foundational to Python and worth restating precisely: assignment binds a name to an existing object; it never implicitly duplicates that object. Two names bound to the same mutable object will observe each other's mutations, because there is, in fact, only one object.

original = [1, 2, 3]
alias = original             # NOT a copy -- alias names the SAME list

alias.append(4)
print(original)              # [1, 2, 3, 4] -- mutation visible via original too
print(original is alias)     # True -- same object identity

Shallow Copy

A shallow copy creates a new outer object, populated with references to the exact same inner objects as the original. The top-level container is independent; anything nested inside it is still shared.

Several familiar idioms perform a shallow copy without calling copy.copy() explicitly: list(x), x[:], x.copy() for lists, and dict(d) or d.copy() for dicts. All of them share this same 'new outer container, same inner objects' behavior.

import copy

original = [[1, 2], [3, 4]]
shallow = copy.copy(original)      # or: original[:] / original.copy() / list(original)

print(shallow is original)          # False -- different outer list objects
print(shallow[0] is original[0])    # True -- same inner list objects, shared!

shallow.append([5, 6])              # only affects the shallow copy's own top level
print(original)                      # [[1, 2], [3, 4]] -- unaffected

shallow[0].append(99)                # mutates a SHARED nested list
print(original)                       # [[1, 2, 99], [3, 4]] -- original changed too!

Deep Copy

A deep copy recursively duplicates the object and everything reachable from it, producing a fully independent structure where mutating any part of the copy — at any nesting depth — never affects the original, and vice versa.

import copy

original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)

print(deep is original)          # False
print(deep[0] is original[0])    # False -- inner lists are independent too

deep[0].append(99)
print(original)                   # [[1, 2], [3, 4]] -- completely unaffected
print(deep)                        # [[1, 2, 99], [3, 4]]

Shallow vs. Deep: Side by Side

For a flat structure with no mutable nested objects (e.g. a list of ints and strings), shallow and deep copies behave identically, because there's nothing nested to share in the first place — the distinction only matters once containers hold other mutable containers.

Operation          New outer object?   Nested objects shared or duplicated?
------------------  -------------------  -------------------------------------
a = b (assignment)   No  (same object)    N/A -- it's the same object entirely
copy.copy(x)          Yes                 Shared (same nested object references)
copy.deepcopy(x)      Yes                 Duplicated recursively, fully independent

copy.copy and copy.deepcopy

The standard library's copy module provides these two functions as the general, type-agnostic way to copy arbitrary objects, including your own custom classes — not just built-in containers.

import copy

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class Line:
    def __init__(self, start, end):
        self.start = start
        self.end = end

line = Line(Point(0, 0), Point(1, 1))

shallow_line = copy.copy(line)
print(shallow_line.start is line.start)   # True -- same Point object

deep_line = copy.deepcopy(line)
print(deep_line.start is line.start)       # False -- independent Point

Customizing Copy Behavior

A class can override how copy.copy() and copy.deepcopy() treat its instances by defining __copy__ and/or __deepcopy__ methods — useful when default field-by-field copying isn't correct for the class (e.g. it wraps an external resource, or needs to reset some derived state on copy).

__deepcopy__ receives a memo dictionary argument, used to track objects already copied during the current deepcopy call (see the next section on cycles) — pass it along to copy.deepcopy() for any nested attributes you copy manually inside __deepcopy__.

import copy

class ConnectionPool:
    def __init__(self, size):
        self.size = size
        self.active_connections = 0   # should reset on copy, not be shared

    def __copy__(self):
        new = ConnectionPool(self.size)
        new.active_connections = 0    # deliberately don't copy live state
        return new

    def __deepcopy__(self, memo):
        # for this class, shallow and deep behavior happen to coincide
        return self.__copy__()

pool = ConnectionPool(size=10)
pool.active_connections = 4
copied = copy.copy(pool)
print(copied.active_connections)   # 0 -- custom logic, not a field-by-field copy

Recursive and Cyclic Structures

copy.deepcopy() safely handles structures that reference themselves, directly or indirectly — a naive recursive copy implementation would infinite-loop on a cycle, but deepcopy tracks every object it has already copied (in an internal memo dictionary) and reuses that copy instead of recursing again.

import copy

class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

a = Node("a")
a.next = a                    # a self-referencing cycle

copied = copy.deepcopy(a)     # does NOT infinite-loop
print(copied is copied.next)   # True -- the cycle is preserved in the copy
print(copied is a)              # False -- but it's a fully independent copy

Practical Usage: Choosing the Right Copy

The rule of thumb: use no copy (plain assignment) when you want an alias by design; use a shallow copy when you need an independent top-level container but the elements themselves are immutable or intentionally meant to be shared; use a deep copy when you need a fully independent structure, including everything nested inside it — most commonly before passing mutable data into code that might mutate it, or when snapshotting state you plan to compare against later.

def process_config(config: dict) -> dict:
    # don't mutate the caller's dict; work on an independent copy,
    # deep because config values might themselves be nested dicts/lists
    import copy
    working_copy = copy.deepcopy(config)
    working_copy["processed"] = True
    return working_copy

original_config = {"settings": {"debug": False}}
result = process_config(original_config)
print(original_config)   # {'settings': {'debug': False}} -- untouched

Common Immutable-Type Shortcut

Copying an immutable object (an int, a str, a tuple of immutables) is essentially never necessary, since it can't be mutated in place anyway — and CPython often (as an implementation detail, not a guarantee) returns the same object from copy.copy() on an immutable, rather than actually duplicating it, because doing so is always observationally safe.

import copy

s = "hello"
print(copy.copy(s) is s)     # True, typically (CPython optimization; not guaranteed
                              # by the language, but harmless either way since
                              # str is immutable)

Mental Model

Picture a filing cabinet where a folder can contain smaller folders inside it. Assignment is handing someone the same folder — there's still only one folder, and anything either of you puts in it is visible to both. A shallow copy is photocopying just the outer folder's cover and its list of contents, but the smaller folders inside are literally the same physical folders as before — open one from the copy and you're looking at the original. A deep copy is running the entire folder, and every folder nested inside it, through the photocopier, recursively, until nothing physical is shared with the original at any depth.

Quick Reference

a = b                    -> alias; same object, no copy at all
copy.copy(x)              -> shallow copy; new outer, shared inner objects
copy.deepcopy(x)           -> deep copy; fully independent, recursive
x[:], list(x), x.copy()    -> shallow copy shortcuts for sequences
dict(d), d.copy()           -> shallow copy shortcuts for dicts
__copy__(self)               -> customize shallow-copy behavior for a class
__deepcopy__(self, memo)      -> customize deep-copy behavior; use memo for
                                  nested copies to correctly handle cycles

Key Takeaways

Assignment never copies an object in Python — it only creates another name bound to the same object.

A shallow copy (copy.copy, or slicing/[:] and .copy() shortcuts) duplicates only the outer container; nested mutable objects remain shared with the original.

A deep copy (copy.deepcopy) recursively duplicates everything reachable from the object, producing a structure with nothing shared with the original at any depth.

Shallow and deep copies behave identically on flat structures with no nested mutable objects — the distinction only matters once containers hold other containers.

Custom classes can define __copy__ and __deepcopy__ to override default field-by-field copying, and deepcopy correctly handles cyclic/self-referencing structures via an internal memo of already-copied objects.

matrix = [[0] * 3] * 3 # looks like a 3x3 grid of independent rows matrix[0][0] = 1 print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] -- all rows changed!

matrix = [[0] * 3 for _ in range(3)] # each row is a genuinely separate list matrix[0][0] = 1 print(matrix) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]] -- only row 0 changed

[[0] * 3] * 3 repeats the SAME inner list three times (an aliasing bug, not even a shallow-copy issue) -- all three 'rows' are one shared list object; build each row independently with a list comprehension instead.

import copy original = {"items": [1, 2, 3]} backup = copy.copy(original) # shallow -- looks safe, isn't backup["items"].append(4) print(original["items"]) # [1, 2, 3, 4] -- original was mutated too!

import copy original = {"items": [1, 2, 3]} backup = copy.deepcopy(original) # deep -- nested list is independent too backup["items"].append(4) print(original["items"]) # [1, 2, 3] -- unaffected

A shallow copy's nested mutable objects (like the 'items' list here) are shared with the original, not duplicated; mutating them through the copy mutates the original too. Use deepcopy when nested mutable state must be independent.

def add_default(items=None): items = items or [] items.append("default") return items # caller might pass a shared list expecting it NOT to be mutated in place def add_default(items=None): items = list(items) if items is not None else [] items.append("default") return items # a shallow copy of the caller's list protects it from in-place mutation

Functions that receive a mutable argument and then mutate it in place silently affect the caller's object too, since no copy occurred; copy the input first (shallow is enough here since only top-level append happens) if the function shouldn't mutate what it was given.

What is the essential difference between a = b and copy.copy(b) for a mutable object b?

a = b creates a second name bound to the exact same object as b -- there is still only one object, and mutating it through either name is visible through both. copy.copy(b) creates a genuinely new, independent outer object with the same top-level contents, so mutating the copy's top level does not affect b.

Given nested = [[1, 2], [3, 4]] and shallow = copy.copy(nested), why does shallow[0].append(99) also change nested?

copy.copy only duplicates the outer list; the inner lists inside it are the same objects referenced by both nested and shallow. shallow[0] and nested[0] are literally the same list object, so mutating it through either name affects both.

When do a shallow copy and a deep copy of the same object behave identically?

When the object is flat -- it contains no nested mutable objects (e.g. a list of ints and strings) -- there is nothing for the two copy strategies to treat differently, since there's no shared nested structure to worry about either way.

How does copy.deepcopy() avoid infinite recursion when copying a self-referencing or cyclic structure?

It maintains an internal memo dictionary that tracks every object it has already copied during the current deepcopy call; when it encounters a reference to an object it has already started copying, it reuses that in-progress copy instead of recursing into it again, preserving the cycle's shape in the copy without looping forever.

What two special methods let a custom class override the default field-by-field behavior of copy.copy() and copy.deepcopy()?

__copy__(self) customizes shallow-copy behavior, and __deepcopy__(self, memo) customizes deep-copy behavior; the memo argument should be passed along when manually deep-copying any nested attributes inside __deepcopy__, so cycles and shared references are still handled correctly.

Why is [[0] * 3] * 3 a common bug when someone intends to build a 3x3 grid of independent rows?

The outer * 3 repeats the exact same inner list object three times -- it is an aliasing bug (not even a copying issue), so all three 'rows' in the resulting list are literally the same list; mutating one index in any 'row' changes it in every row. A list comprehension like [[0]*3 for _ in range(3)] creates three genuinely separate inner lists instead.

Write a function deep_equal_but_independent(original) that returns a deep copy of a nested list/dict structure, and demonstrate with an assertion that the copy is equal in value but shares no mutable sub-objects with the original.

import copy def deep_equal_but_independent(original): return copy.deepcopy(original) data = {"users": [{"name": "Ana"}, {"name": "Bo"}]} snapshot = deep_equal_but_independent(data) assert snapshot == data # equal in value assert snapshot is not data # different outer object assert snapshot["users"] is not data["users"] # different list object assert snapshot["users"][0] is not data["users"][0] # different nested dict too snapshot["users"][0]["name"] = "Changed" assert data["users"][0]["name"] == "Ana" # original untouched print("all assertions passed") copy.deepcopy recursively duplicates every nested container (the outer dict, the list, and each inner dict), so the snapshot is equal by value (==) to the original but shares no mutable object at any depth (is comparisons at every level are False except for genuinely immutable leaf values); mutating the snapshot's nested data therefore never affects the original.

What do the three print statements output, and why?

Output: [1, 2, 3, 4], then [1, 2, 3, 4], then True. copy.copy(original) creates a new Bag object but does not duplicate its items attribute -- both Bag instances' .items point to the exact same list object, so appending through shallow.items also shows up on original.items, and the identity check confirms they are literally the same list.

Write a class Matrix wrapping a list of row-lists, and define __deepcopy__ on it that produces a fully independent Matrix (independent rows), using the memo dictionary correctly.

import copy class Matrix: def __init__(self, rows): self.rows = rows def __deepcopy__(self, memo): new_matrix = Matrix.__new__(Matrix) memo[id(self)] = new_matrix # register before recursing, in case of cycles new_matrix.rows = copy.deepcopy(self.rows, memo) return new_matrix m = Matrix([[1, 2], [3, 4]]) m2 = copy.deepcopy(m) m2.rows[0][0] = 99 print(m.rows) # [[1, 2], [3, 4]] -- unaffected print(m2.rows) # [[99, 2], [3, 4]]

__deepcopy__ creates a new Matrix instance, registers it in the memo dict keyed by id(self) before recursing (the standard pattern for cycle safety), then deep-copies self.rows through copy.deepcopy(self.rows, memo) so the nested row lists are independently duplicated too; the result shares nothing mutable with the original, as shown by mutating m2's rows without affecting m's.

class Bag:
    def __init__(self, items):
        self.items = items

original = Bag([1, 2, 3])
import copy
shallow = copy.copy(original)
shallow.items.append(4)
print(original.items)
print(shallow.items)
print(original.items is shallow.items)