Python shallow copy vs deep copy: trace a nested-data bug
Follow a nested dictionary through assignment, shallow copying and deep copying, then choose the right copying boundary for a data workflow.
A shallow copy creates a new outer container while retaining references to its contents. A deep copy recursively copies the objects it contains, subject to the copying behavior those objects define. Assignment alone creates another reference to the same object. The right choice depends on which parts your next operation will mutate.
This distinction matters when you prepare several configurations or examples from one template. A new outer dictionary can look independent while its nested list still belongs to the original. We will reproduce that bug using invented experiment settings, inspect the references, and fix the boundary that caused it. The example uses only Python's standard library.
Reproduce the shared-list bug
Run this complete script. The assertions check identity as well as values: equal contents do not imply independent objects.
from copy import deepcopy
template = {"name": "baseline", "features": ["age", "visits"]}
alias = template
shallow = template.copy()
deep = deepcopy(template)
assert alias is template
assert shallow is not template
assert shallow["features"] is template["features"]
assert deep["features"] is not template["features"]
shallow["name"] = "candidate"
shallow["features"].append("region")
print("template:", template)
print("shallow:", shallow)
print("deep:", deep)
independent = {**template, "features": template["features"].copy()}
independent["features"].append("tenure")
assert "tenure" not in template["features"]
print("independent features:", independent["features"])
Expected output:
template: {'name': 'baseline', 'features': ['age', 'visits', 'region']}
shallow: {'name': 'candidate', 'features': ['age', 'visits', 'region']}
deep: {'name': 'baseline', 'features': ['age', 'visits']}
independent features: ['age', 'visits', 'region', 'tenure']
The first mutation replaces a value in shallow, so the template's name stays unchanged. The second mutation follows the shared reference and changes the list itself. Both dictionaries now observe the longer list. The deep copy was taken before that mutation and retains an independent list.
Draw the object boundary
Think of the state immediately after copying:
| Variable | Outer dictionary | Nested feature list |
|---|---|---|
alias | Same as template | Same as template |
shallow | New | Same as template |
deep | New | New |
The relevant question is where the program writes. Replacing shallow["features"] with a new list would write into the new dictionary. Calling .append() writes into the existing list. The syntax begins similarly, but the modified object differs.
This also explains why copying a list of dictionaries once does not isolate every dictionary. The new list can still point to the original row objects. If a cleaning function changes a field inside each row, copy those rows or redesign the function to return new records.
Choose the smallest useful copy
The final part of the script copies the outer dictionary and the feature list that will change. This is sufficient for this particular structure because the list contains immutable strings. If it contained dictionaries with mutable values, another layer of sharing could remain.
Use an explicit copy when ownership is easy to describe. It documents the fields a workflow may change and avoids recursively copying unrelated data. Use deepcopy when recursive isolation is actually required and the objects support that operation. Measure its cost for large structures instead of assuming copying is free.
A deep copy does not mean every resource has been independently recreated. Files, sockets and other external resources do not acquire new real-world identities through this operation. User-defined classes can customize copying. Python's copy documentation describes these boundaries and the memo mechanism used for recursive structures.
A correction is different from hiding a mutation
Before adding copies throughout a program, identify who should own the data. A function that silently mutates its input can surprise callers even when those callers remember to copy it. A function that returns a transformed structure and documents its behavior is often easier to reason about.
For a large array, copying may be particularly expensive. NumPy also distinguishes copies from views, but uses array-specific rules. Continue with NumPy views vs copies to see why familiar list-slicing intuition does not transfer directly.
Check your understanding
Replace the feature list with [{"name": "age"}]. Make a shallow copy, then change the nested dictionary's name. Predict which variables observe the change before running it. Next, isolate only that nested dictionary and verify the original stays unchanged.
The goal is to name the exact object each operation mutates. Use the Python resources for related explanations and the Python course for the ordered language foundations.