Create your own
Lesson illustration

Choosing Shallow vs. Deep Copy for Independent Nested State

Welcome back. In the previous lesson, you diagnosed unintended mutation by tracing identities through a nested object graph: a shallow copy can create a new outer container while preserving references to mutable descendants.

This lesson turns that diagnosis into a construction decision. You will decide when a new outer container is sufficient, when the whole mutable hierarchy must be isolated with deepcopy(), and when a targeted copy of one branch is the clearest and most efficient design. The central question is not “how nested is this data?” but which objects may be mutated, and which ones must remain independent?


Copying is an ownership decision

Consider a policy template used to generate candidate configurations:

baseline = {
    "policy_version": 7,
    "sites": [
        {
            "name": "chennai",
            "prefixes": ["10.20.0.0/16"],
            "probes": {"interval_seconds": 5},
        }
    ],
}

Suppose a candidate needs a different top-level version number:

candidate = baseline.copy()
candidate["policy_version"] = 8

A shallow copy is exactly right here. candidate and baseline are different outer dictionaries, so assigning into the outer candidate dictionary does not affect baseline.

assert candidate is not baseline
assert candidate["policy_version"] == 8
assert baseline["policy_version"] == 7

But the nested site data remains shared:

assert candidate["sites"] is baseline["sites"]
assert candidate["sites"][0] is baseline["sites"][0]
assert candidate["sites"][0]["prefixes"] is baseline["sites"][0]["prefixes"]

Therefore, this leaks into the baseline:

candidate["sites"][0]["prefixes"].append("10.30.0.0/16")

print(baseline["sites"][0]["prefixes"])
# ['10.20.0.0/16', '10.30.0.0/16']

The shallow copy did not fail. It fulfilled its contract: it created a new outer dictionary and copied references to its values. The error would be expecting it to provide isolation that was never requested.

The `inventory` and `backup` names refer to separate outer dictionaries, but both outer dictionaries refer to the same nested `fruits` and `dairy` dictionaries. A nested mutation through either path is therefore visible through both names; the later-added `seafood` key exists only in `inventory`.

A useful rule is:

A copy is sufficient only if every object you plan to mutate is owned exclusively by the copy.

For a shallow copy, that is normally true only for the outer container.

How To Copy Lists In Python - Shallow Copy vs Deep Copy

Watch “How To Copy Lists In Python - Shallow Copy vs Deep Copy” by Joseph Loves Python for a compact visual treatment of why a shallow copy duplicates the outer list but retains references to nested objects.

Start at shallow copying and follow the transition from a flat list, where a shallow copy is enough, to a nested list, where it is not. Then watch deep copying to see how recursive copying changes the identity relationships.


What shallow copying actually guarantees

A shallow copy creates a new compound object and places references to the original children inside it. For standard built-in containers, common shallow-copy forms are:

new_list = old_list.copy()
new_list = list(old_list)
new_list = old_list[:]

new_dict = old_dict.copy()
new_dict = dict(old_dict)

new_set = old_set.copy()

For arbitrary objects, the generic standard-library operation is:

from copy import copy

replica = copy(original)

For a built-in dict or list, prefer .copy() when that is all you need. It communicates the intended operation directly.

Shallow copying is an appropriate default in either of these conditions:

  1. You will mutate only the new outer container.
  2. The descendants are immutable values such as strings, integers, bytes, or frozen sets.
  3. Nested objects are deliberately shared state and your code will not mutate them through this copy.

For example, a list of strings has an independent list structure after a shallow copy:

interfaces = ["wan0", "wan1"]
candidate_interfaces = interfaces.copy()

candidate_interfaces.append("wan2")
candidate_interfaces[0] = "uplink0"

print(interfaces)
# ['wan0', 'wan1']

print(candidate_interfaces)
# ['uplink0', 'wan1', 'wan2']

Strings are immutable. Replacing "wan0" in candidate_interfaces changes a slot in the independent outer list; it does not mutate the original string.

Be careful with “immutable container” as a shortcut. A tuple itself cannot be modified, but it can contain a mutable descendant:

settings = ("branch-a", {"prefixes": []})

Every reference to settings can still mutate settings[1]["prefixes"]. What matters is the mutability of the objects you may reach and change, not merely the type of the outermost container.

How to Copy Objects in Python: Shallow vs Deep Copy Explained – Real Python

Read the relevant portions of Real Python’s “How to Copy Objects in Python” to reinforce the object-graph model with the inventory example and to compare the practical trade-offs of shallow and deep copying.

In “Exposing the Pitfalls of Shallow Copying,” read from the surprising nested update. Focus on the identity checks for the outer dictionary versus the category dictionaries. Then read all of “Deep Copy: Creating Independent Clones,” from the deep-copy implementation. Finally, in “Comparing Shallow and Deep Copying,” read the shopping-cart discussion from the immutable-elements example, then stop before the recursive-data-structure detour.


Deep copying for an independent mutable hierarchy

Use copy.deepcopy() when the copy is a genuinely independent working state: a sandbox, a baseline snapshot, a per-test mutable fixture, or a configuration that will be modified at arbitrary nested paths.

from copy import deepcopy

candidate = deepcopy(baseline)

candidate["sites"][0]["prefixes"].append("10.30.0.0/16")
candidate["sites"][0]["probes"]["interval_seconds"] = 10

print(baseline)
# {'policy_version': 7,
#  'sites': [{'name': 'chennai',
#             'prefixes': ['10.20.0.0/16'],
#             'probes': {'interval_seconds': 5}}]}

For normal nested built-in data, the relevant identity checks now show isolation:

assert candidate is not baseline
assert candidate["sites"] is not baseline["sites"]
assert candidate["sites"][0] is not baseline["sites"][0]
assert candidate["sites"][0]["prefixes"] is not baseline["sites"][0]["prefixes"]
assert candidate["sites"][0]["probes"] is not baseline["sites"][0]["probes"]

The phrase “deep copy creates entirely new objects” needs one refinement. deepcopy() aims to isolate the mutable object graph from the original. It may reuse immutable objects because there is no mutation risk:

assert candidate["sites"][0]["name"] is baseline["sites"][0]["name"]

Do not use identity of strings or integers to judge whether a deep copy worked. Test the identities of the mutable objects whose ownership matters.

Deep copy preserves internal sharing

deepcopy() also preserves the shape of the original graph. If two keys intentionally refer to one list before copying, they will refer to one newly copied list afterwards:

shared_neighbors = ["edge-a"]

topology = {
    "primary": shared_neighbors,
    "secondary": shared_neighbors,
}

clone = deepcopy(topology)

assert clone["primary"] is clone["secondary"]
assert clone["primary"] is not topology["primary"]

This is correct behavior. The clone is independent from topology, while retaining the original relationship that "primary" and "secondary" name the same list.

It also explains why deepcopy() can handle cycles. Internally, it tracks objects already copied during the current operation, avoiding infinite recursion and ensuring a shared object is copied once rather than duplicated inconsistently.


Do not treat deepcopy() as the automatic answer to nesting

A nested structure does not automatically require a deep copy. A deep copy has costs:

  • It traverses and duplicates much more of the object graph.
  • It can duplicate data that should remain shared, such as registries, caches, or service objects.
  • Some external-resource objects, such as open files, sockets, modules, and locks, cannot sensibly be copied.
  • For custom classes, copying semantics may be specialized by the class author.

For configuration and API payload data composed of dictionaries, lists, sets, and scalar values, deepcopy() is usually predictable. For object graphs that include resources or domain objects with intentional sharing, make ownership explicit rather than applying deepcopy() blindly.

Often there is a third, precise option: copy only the path you intend to change.

Suppose only the first site’s prefixes must diverge. A deep copy would isolate every site and every nested object, even though only one prefix list needs independent ownership.

candidate = baseline.copy()

sites = baseline["sites"].copy()
candidate["sites"] = sites

first_site = baseline["sites"][0].copy()
sites[0] = first_site

prefixes = baseline["sites"][0]["prefixes"].copy()
first_site["prefixes"] = prefixes

prefixes.append("10.30.0.0/16")

Now the edited path is isolated:

assert candidate["sites"] is not baseline["sites"]
assert candidate["sites"][0] is not baseline["sites"][0]
assert candidate["sites"][0]["prefixes"] is not baseline["sites"][0]["prefixes"]

But unmodified branches remain shared:

assert (
    candidate["sites"][0]["probes"]
    is baseline["sites"][0]["probes"]
)

That last assertion is not a bug if the copy’s contract says probe settings are read-only. It becomes a bug if later code mutates that dictionary.

This is analogous to a copy-on-write policy: create new containers along the route to the value that changes, while safely sharing everything outside that route. It is a strong option for large policy trees, especially when a change is localized and the intended ownership is well documented.


A practical selection procedure

Before copying a structure, state the intended mutation contract in one sentence. For example:

  • “This function adds only request metadata.”
  • “Each test case must be able to modify any nested field.”
  • “This candidate changes prefixes for one site only.”
  • “These two views must intentionally share live status.”

Then select the construction that matches it:

Intended behaviorAppropriate approachImportant consequence
No mutation at allReuse the objectSharing is harmless when state remains read-only.
Only outer keys or items changeShallow copyNested descendants remain shared.
Outer container contains only immutable descendantsShallow copyShared leaves cannot be mutated in place.
Any nested mutable field may changecopy.deepcopy()Mutable descendants become independent from the original.
One known nested branch changesCopy the containers along that pathOther branches remain shared by design.
Live shared state is requiredReuse or shallow-copy deliberatelyDocument that mutations are observable through all aliases.

After selecting an approach, write assertions at the ownership boundary rather than relying only on printed output:

# A candidate expected to alter nested prefixes independently
assert candidate["sites"] is not baseline["sites"]
assert (
    candidate["sites"][0]["prefixes"]
    is not baseline["sites"][0]["prefixes"]
)

These assertions capture the design requirement directly. They are particularly valuable for test fixtures and configuration-generation code, where a future refactor can accidentally replace a deep or targeted copy with a shallow one.


Key takeaways

A shallow copy creates a new outer container while retaining references to its descendants. It is the right choice when you mutate only the outer structure, when descendants are immutable, or when nested state is intentionally shared.

copy.deepcopy() recursively copies the mutable structure needed to isolate a working state from its original. Use it when nested mutations may occur at arbitrary paths and the original must stay unchanged. Verify isolation at mutable boundaries with is not, not by checking identities of immutable leaves.

For a localized nested update, a targeted path copy is often clearer than copying an entire configuration tree: each container on the mutation path becomes independent, while untouched branches remain shared intentionally.

Next, you will move from object identity to a related but distinct property: determining whether an object is hashable and therefore suitable as a dictionary key or set member.

Can't find a good explanation? Sign up and we'll make it for you

Sign up