backend / python core / tricky questions / 48_dict_shallow_copy_trap.md

Dict shallow copy trap — nested mutables are shared

3 min read source

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.
  • tuple instead of list for sequences.
  • frozenset instead of set.
  • @dataclass(frozen=True) for records.
  • pydantic models with frozen = True config.

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.