Skip to content
← All articles

Shallow vs deep copy, the memo dict, and copy.replace

Shallow-copying a request and mutating a nested list is cross-request data corruption. Reflexively reaching for deepcopy turns a 40 ms endpoint into a 400 ms one — measured, 1.709 ms vs 0.00051 ms on a 1,000-element nested structure, about 3,300x. Both are wrong; the fix is deciding per field.

Start with the canonical demonstration, because everything else follows from it:

grid = [[0] * 3] * 3
grid[0][0] = 9
print(grid)      # [[9, 0, 0], [9, 0, 0], [9, 0, 0]]

[0] * 3 builds one list. [...] * 3 then builds an outer list containing three references to that one inner list. There is one row, viewed three times. Nothing was copied, because * on a list copies references, which is what every operation in Python does.

The two failure modes, and they are opposite

Too shallow. A web framework hands each request a config object. A handler does cfg = copy.copy(base_config) and then cfg.headers["x-tenant"] = tenant. The headers dict was never copied — copy.copy copied the reference to it — so tenant A’s header is now on the base config, and tenant B sees it. This is not a slow leak; it is data crossing a request boundary, which in a multi-tenant system is a security incident.

Too deep. Someone gets burned by the above and adds copy.deepcopy everywhere. Measured on a 1,000-element nested dataclass structure: deepcopy 1.709 ms, copy.copy 0.00051 ms — about 3,300x. Do that four times per request and a 40 ms endpoint becomes a 400 ms one, and the flame graph attributes it all to copy, which looks like framework overhead rather than a decision anyone made.

Neither reflex is right, because the correct answer is per-field. Which parts of this structure will be mutated by the new owner, and which are genuinely shared?

What deepcopy does that copy does not

The documentation names both problems it solves:

  1. Recursive objects. A structure that contains a reference to itself — directly, or through a cycle — would make a naive recursive copy loop forever.
  2. “Because deep copy copies everything it may copy too much, such as data intended to be shared between copies.”

The mechanism for the first is the memo dictionary: deepcopy keeps {id(original): copy} for everything it has already copied, so a second encounter with the same object returns the same copy rather than making another. That gives you two properties at once — termination on cycles, and structure preservation: if the original had two fields pointing at the same list, so does the copy, and it is the same list in both. Without the memo you would get two lists and a subtly different object graph.

You can pass the memo yourself, and doing so is the key to selective copying: copy.deepcopy(value, memo) across several calls shares one memo, so cross-references between the fields you copy are preserved.

What copy refuses

copy and deepcopy do not copy modules, methods, stack traces, stack frames, file objects, sockets, or similar OS-level handles. Functions and classes are returned unchanged, by design — a copy of a function would not be a useful object.

Anything else that cannot be reduced raises TypeError, and the message is about pickling, because that is the protocol deepcopy falls back on:

copy.deepcopy(math)          # TypeError: cannot pickle 'module' object
copy.deepcopy(gen)           # TypeError: cannot pickle 'generator' object

That failure arrives from deep inside copy with no reference to your structure. Translating it at your own boundary — “section ‘plugins’ is not deep-copyable” — is the difference between a bug report someone can act on and one they cannot.

copy.replace() and __replace__ (3.13)

Python 3.13 generalised what dataclasses.replace did for dataclasses:

import copy

new = copy.replace(config, retries=5)

Any object can support it by implementing __replace__. Dataclasses and namedtuple get it for free. It is a shallow operation — the unchanged fields are shared, not copied — which is usually what you want for an immutable value type and is worth being explicit about when the fields are mutable.

Note the sharp edge that survived the generalisation: dataclasses.replace() on a field declared init=False raises TypeError, not the ValueError the documentation claims. There is nothing to do about it except know which one to catch.

💡Why does the *type checker* actively hide the difference between copy and deepcopy, and what does that tell you about where types stop helping? click to reveal

Both are typed (T) -> T. From the checker’s point of view copy.copy(cfg) and copy.deepcopy(cfg) produce the same thing — a Config — because that is true. The type is identical; only the aliasing differs, and aliasing is not part of Python’s type system.

This is a clean, small demonstration of the boundary. Static types describe the shape of values: what attributes exist, what a call returns, which branches are reachable. They say nothing about ownership, mutability-in-practice, or whether two names reach the same object. Rust’s borrow checker exists precisely to track that second category, and Python has no equivalent.

So the practical consequence: aliasing bugs are exactly the class of bug that mypy --strict will never find, and therefore the class where code review and naming have to do the work. Conventions that help — take Sequence[T] and store list(...) at a boundary so the copy is visible; prefer frozen dataclasses so there is nothing to alias; name a function clone_config rather than get_config when it copies. None of that is enforced. All of it is legible.

💡You are writing clone_config(cfg, deep_keys=...), which deep-copies some fields and shares the rest. Why should the several deepcopy calls share one memo dict rather than each getting a fresh one? click to reveal

Because a shared memo preserves cross-references between the deep-copied fields, and separate memos destroy them.

Suppose cfg.sections["a"] and cfg.sections["b"] both contain a reference to the same Limits object, and both keys are deep. With one memo, the first call copies Limits and records it; the second call finds it and reuses the same copy, so the clone has the same sharing the original did — mutating the clone’s limits through a is visible through b, exactly as in the original. With separate memos you get two distinct Limits copies, and the clone now has a different shape from the original. That is a bug that only appears when someone mutates through one path and reads through the other, which is to say: rarely, and confusingly.

The shared memo also handles the self-referential case for free. A config that reaches itself is copied once and every path to it resolves to the same copy, rather than recursing forever.