How does copy.deepcopy's memo handle shared references and reference cycles?
answer
- It remembers what it has already copied
- Keyed by object identity, not value
- Registered before its children are walked
- Aliasing survives; cycles terminate
- Cost scales with the whole reachable graph
basics
~20 scopy.deepcopy keeps a memo dictionary mapping each already-copied object's id() to its new copy. A second encounter with the same object reuses that copy, so aliasing in the original is reproduced in the result and reference cycles terminate instead of recursing forever.
solid answer
~50 s`copy.deepcopy` walks the object graph and records every object it copies in a **memo** dict keyed by `id(obj)`, whose value is the new copy. Before copying anything it checks the memo, so the second time it meets an object it reuses the existing copy. Two properties fall out of that: **shared references are preserved** — if two fields aliased one object in the original, they alias one copy in the result — and **cycles terminate**, because the object is registered in the memo before its children are walked. The memo also keeps the original objects alive for the duration so `id()` values cannot be recycled mid-walk. Immutable atomics, plus classes, functions and modules, are returned as-is rather than copied. Anything without a copy hook falls back to `__reduce_ex__`, which is why deep-copying an object holding a lock or a socket raises `TypeError`, and a very deep chain raises `RecursionError`.
code
python · 11 linesimport copy
shared = {"tenant": "acme"}
context = {"a": shared, "b": shared}
context["self"] = context
snapshot = copy.deepcopy(context)
print(snapshot["a"] is snapshot["b"]) # True - aliasing preserved
print(snapshot["a"] is shared) # False - it is a copy
print(snapshot["self"] is snapshot) # True - cycle rebuilt, no hanggo deeper
Know that copy.deepcopy duplicates nested objects rather than sharing them, and that it is the tool for making a nested structure genuinely independent of the original.
Explain the memo as a map from object identity to its copy, and use it to say why cycles do not hang and why two aliased fields stay aliased after the copy.
Show judgement about cost and failure: know that deep copying walks the entire reachable graph, that live resources raise TypeError, and that deep chains raise RecursionError, and offer a narrower snapshot instead.
Own the state-ownership strategy: whether a system snapshots by deep copying, by immutability, or by building derived state and committing, and what that choice costs in memory and latency under concurrency.
### The algorithm in one paragraph `copy.deepcopy(x)` is a recursive graph walk with memoisation. For each object it reaches it first asks: *have I copied this one already?* by looking up `id(obj)` in a **memo** dict that is threaded through the whole traversal. On a hit it returns the stored copy. On a miss it constructs a new object, **stores it in the memo before recursing into the children**, and then deep-copies the children into it. That ordering is the entire trick, and it buys two guarantees at once. ### Guarantee one: shape is preserved, not just contents If two attributes of the original point at one object, the copy's two attributes point at one *copy* of it — not two independent duplicates. Deep copying reproduces the graph's topology, not merely its values: ```python import copy shared = {"tenant": "acme"} context = {"a": shared, "b": shared} snapshot = copy.deepcopy(context) print(snapshot["a"] is snapshot["b"]) # True - aliasing preserved print(snapshot["a"] is shared) # False - but it is a copy ``` Without the memo, `snapshot["a"]` and `snapshot["b"]` would be two separate dicts and a later mutation through one would not be seen through the other — silently changing behaviour that the original relied on. ### Guarantee two: cycles terminate Because the new object is registered in the memo *before* its children are walked, a cycle that leads back to an already-seen object finds it in the memo and stops: ```python context = {"id": 1} context["self"] = context snapshot = copy.deepcopy(context) print(snapshot["self"] is snapshot) # True - the cycle is rebuilt ``` The memo additionally holds a keep-alive list of the original objects, so none of them can be garbage-collected mid-walk and have its `id()` reused by a fresh object — which would otherwise corrupt the mapping. ### What deepcopy refuses to copy Not everything is duplicated. Immutable atomics — `int`, `float`, `str`, `bytes`, `bool`, `None` — are returned unchanged, because copying them would be pure waste. So are classes, functions and modules: a deep copy of an object holding a reference to a class gives you the same class, not a clone of it. For everything else without a dedicated hook, `copy` falls back to the pickle-style reduction protocol via `__reduce_ex__`, which is why an object holding a non-serialisable resource fails loudly: ```python import copy import threading try: copy.deepcopy(threading.Lock()) except TypeError as exc: print(type(exc).__name__) # TypeError ``` A lock, a socket, an open file or a live connection cannot be reduced, so the deep copy raises `TypeError` rather than producing a broken clone. Deep chains — a linked structure tens of thousands of nodes long — raise `RecursionError` instead, since the walk is recursive. ### The cost, and where it bites The walk is proportional to the whole reachable graph and allocates a new object per node, with a dict insert and lookup per node on top. On a request path that matters. Consider a fraud-scoring service that deep-copies its request context before applying rules so a partial-failure rollback can restore the pre-rule state. The context holds a handful of mutable per-request fields — and a reference to the shared reference-data map the rules read, which is serving an 83% cache-hit rate precisely because every request shares one instance of it. `copy.deepcopy(context)` duplicates that map per request: the cache-hit rate collapses to nothing useful because each request now reads its own copy, memory scales with concurrency instead of staying flat, and latency grows with the size of data nobody mutates. The fix is to stop treating "snapshot" as "deep copy everything". Better options, roughly in order: * **Copy only what can change.** Rebuild the small set of mutable per-request fields and keep the shared read-only data aliased. * **Make the shared data immutable** — tuples, frozensets, frozen dataclasses — so nothing needs copying and sharing is provably safe. * **Do not snapshot at all.** Apply rules to a fresh derived structure and commit at the end, so rollback means discarding the derived structure rather than restoring the original. * **Give the objects copy hooks** so a deep copy of the context can deliberately share the reference data instead of walking it. ### Interview-ready summary The memo is what makes `copy.deepcopy` *correct* on real object graphs — preserving aliasing and surviving cycles — and it is also a good reminder of what deep copying costs: every distinct reachable object becomes a new allocation. Knowing both halves is the difference between "use deepcopy" and knowing when a deep copy is the wrong tool for a snapshot.
- What happens when you deep-copy a linked structure a hundred thousand nodes deep?It raises `RecursionError`, because the walk recurses per node and exhausts the interpreter's recursion limit. Raising the limit with `sys.setrecursionlimit` is a fragile workaround that trades one crash for a possible stack overflow. The durable fixes are to copy iteratively with your own loop, to give the container a copy hook that flattens the traversal, or to hold the data in a flat structure such as a list in the first place.
- You need a snapshot of request state for rollback, but deep-copying it is too expensive. What do you do instead?Narrow the snapshot to what can actually change: rebuild the few mutable fields and keep large read-only reference data aliased. Better still, make the shared data immutable so it never needs copying, or restructure so the operation builds a derived result and commits at the end, making rollback a matter of discarding that result rather than restoring the original.
- Does copy.deepcopy duplicate classes and functions reachable from the object?No. Classes, functions and modules are treated as atomic and returned as-is, along with immutable scalars such as `int` and `str`. A deep copy of an object whose attribute holds a class gives you a copy of the object bound to the very same class. This is deliberate: cloning a class would produce a type that fails every `isinstance` check the original passed.
saying these in an interview costs you the question
- Thinks deepcopy loops forever on a reference cycle
- Expects two aliased fields to become two independent copies
- Believes deepcopy clones classes and functions too
- Calls deepcopy on a request path without measuring cost
- Assumes any object can be deep-copied successfully