Dict shallow copy trap — nested mutables are shared
The gotcha
d.copy() (or dict(d), or {**d}) creates a new outer dict but copies references for the values. If a value is mutable (list, dict, set, custom object), mutating it affects both the original and the copy.
Minimal repro
original = {'users': [1, 2, 3]}
copy = original.copy()
copy['users'].append(4)
print(original['users']) # [1, 2, 3, 4] ← original mutated too
The outer dict is fresh — adding a new key to copy doesn’t affect original:
copy['extra'] = 'x'
print('extra' in original) # False ← outer keys are independent
But the value [1, 2, 3] is the same list object in both dicts. .append(4) mutates that shared object.
Why it happens
d.copy() walks the keys and copies each (key, value) pair into a new dict. The values are copied by reference — the new dict has new slots pointing at the same value objects. This is “shallow.”
original = {'users': [1, 2, 3]}
copy = original.copy()
original is copy # False
original['users'] is copy['users'] # True ← same list object
The copy is shallow because deep-copying everything is expensive and usually wrong (copying a database connection object you happen to store in a dict, for instance).
All shallow-copy idioms have the same trap
copy1 = original.copy()
copy2 = dict(original)
copy3 = {**original}
copy4 = original | {}
copy5 = {k: v for k, v in original.items()}
All five produce shallow copies. All five share inner mutable values with the original.
How to avoid — copy.deepcopy
import copy
deep = copy.deepcopy(original)
deep['users'].append(4)
print(original['users']) # [1, 2, 3] ← unchanged
deepcopy recursively copies every nested object. Trade-off: slower (~10–100× shallow copy), and copies things you might not want copied (open files, locks, sockets — these typically have their own __deepcopy__ to handle this, or fail loudly).
When this matters in real code
Configuration objects
DEFAULT_CONFIG = {
"retries": 3,
"headers": {"User-Agent": "myapp/1.0"},
}
def make_config(**overrides):
config = DEFAULT_CONFIG.copy()
config.update(overrides)
return config
c1 = make_config()
c2 = make_config()
c1["headers"]["X-Custom"] = "v1" # mutates the SHARED inner dict
print(c2["headers"]) # {'User-Agent': 'myapp/1.0', 'X-Custom': 'v1'}
DEFAULT_CONFIG["headers"] is now polluted. Every call to make_config sees the leak. Fix:
def make_config(**overrides):
config = copy.deepcopy(DEFAULT_CONFIG)
config.update(overrides)
return config
Or design the defaults with no nested mutables:
DEFAULT_CONFIG = {
"retries": 3,
"user_agent": "myapp/1.0", # flat, no nested dict
}
Caching
_cache = {}
def get_users():
if "users" not in _cache:
_cache["users"] = db.query("SELECT * FROM users")
return _cache["users"].copy() # shallow — User rows are shared!
If callers mutate user fields on the returned copy, the cache is corrupted for subsequent reads. Either deep-copy on the way out, or return immutable views (tuple, frozen dataclass).
Test fixtures
@pytest.fixture
def state():
return {"items": [1, 2, 3]}
def test_a(state):
state["items"].append(4) # mutates fixture state...
def test_b(state):
assert state["items"] == [1, 2, 3] # ...visible if fixture is module/session-scoped
Function-scope fixtures dodge this (fresh per test); larger-scope fixtures need explicit care.
Variant: copy of dict containing dataclasses
@dataclass
class User:
name: str
tags: list
original = {"u1": User(name="Alice", tags=["admin"])}
copy = original.copy()
copy["u1"].tags.append("editor")
print(original["u1"].tags) # ['admin', 'editor']
Same problem — the User object (and its tags list) are shared. frozen=True blocks the user-level fields but doesn’t help with mutable contents (tags is still a list).
Frozen-style alternatives
For configs and constants, consider immutable structures:
types.MappingProxyType(d)— read-only view over a dict.tupleinstead oflistfor sequences.frozensetinstead ofset.@dataclass(frozen=True)for records.pydanticmodels withfrozen = Trueconfig.
If callers can’t mutate, the shallow-copy trap can’t bite.
Interview angle
- Q: “What does
original.copy()do for nested values?” — copies references; nested mutables are shared. - Q: “How do you fix it?” —
copy.deepcopy(d). - Follow-up: “When is shallow copy actually fine?” — when values are immutable (str, int, frozen dataclass), or when the caller won’t mutate them.
- Follow-up: “Where does this commonly bite in practice?” — module-level config defaults, caches handing out shared lists, test fixtures with broader-than-function scope.
See 02_python_core/17_shallow_vs_deep_copy.md, 01_mutable_default_arguments.md, 44_dict_fromkeys_shared_default.md.