skip to content

Why does `__deepcopy__` receive a `memo` dict, and what must it store there?

level: middleimportance: should knowfreq 33%

answer

  1. One dict per copy pass
  2. Answers: have I copied this already
  3. Keyed by identity, not equality
  4. Register before recursing, not after
  5. Cycles and shared subgraphs both depend on it

basics

~20 s

The memo maps id(original) to the copy already made for it in the current deepcopy pass. Store memo[id(self)] = new before recursing into attributes, so cycles terminate and objects reachable by two paths stay one object in the copy.

solid answer

~40 s

`copy.deepcopy()` keeps a single dict for the whole pass, keyed by `id()` of each original and valued by the copy made for it. It does two jobs. It terminates recursion: when a graph contains a cycle, the second visit finds the entry and returns the copy already under construction instead of recursing forever. And it preserves structure: two attributes pointing at one list produce two references to *one* new list, not two lists. A hand-written `__deepcopy__` must therefore do two things — assign `memo[id(self)] = new` **before** copying any attribute, and pass the same `memo` into every nested `copy.deepcopy(child, memo)` call. Skip the first and a cyclic graph raises `RecursionError`; skip the second and shared subgraphs are silently duplicated.

code

python · 16 lines
python
import copy


class Node:
    def __init__(self, name):
        self.name = name
        self.peer = None


a = Node("a")
b = Node("b")
a.peer = b
b.peer = a

c = copy.deepcopy(a)
print(c is a, c.peer.peer is c, c.peer is b)

go deeper

for a junior

Know that copy.deepcopy() survives an object graph containing a cycle, and that the extra memo argument in __deepcopy__(self, memo) is what makes that possible rather than an optimization you can ignore.

for a middle

Explain both jobs precisely — cycle termination and preserved sharing — and write the hook in the right order: allocate, assign memo[id(self)] = new, then recurse with copy.deepcopy(child, memo) for every nested attribute.

for a senior

Diagnose from symptoms: a RecursionError inside a copy points at a hook registering itself too late, while a copy whose two attributes stopped sharing one object points at a nested deepcopy call made without the memo.

for a principal

Weigh whether deep copies belong in the design at all — copying a large graph is O(objects) in time and memory, and immutable value types or explicit rebuild functions often remove the need for the mechanism altogether.

## What the memo actually is The memo is an ordinary dict created once per top-level `copy.deepcopy()` call and threaded through every recursive step of that one pass. Its keys are `id()` values of the *originals*; its values are the copies made for them. `copy.deepcopy(x, memo)` accepts it as an optional second parameter precisely so recursion can share it, and `__deepcopy__(self, memo)` receives it for the same reason. The keys are `id()` rather than the objects themselves for a blunt reason: the memo must work for objects that are unhashable (a list, a dict) and for objects whose `__eq__` says two distinct instances are equal. Identity is the only correct key here, since the question being asked is "have I already copied *this very object*?", not "have I copied something equal to it?". ## Job one: terminating cycles Object graphs in real code are rarely trees. A node pointing back at its parent, two services holding each other, a list containing itself — each is a cycle, and a naive recursive copy walks it forever. The memo makes the walk finite. Before descending into an object's contents, `deepcopy` records the (still incomplete) copy under the original's `id()`. When the recursion arrives back at that original, the lookup hits, the partially-built copy is returned, and the cycle closes with the copy pointing at the copy exactly as the original pointed at the original. That ordering is the whole trick, and it is what a hand-written hook most often gets wrong. If your `__deepcopy__` builds the new object, recurses into the attributes, and only then registers itself in the memo, the registration happens after the recursion that needed it. `copy.deepcopy()` does memoize your hook's return value once it comes back, but on a cyclic graph the hook never comes back — the recursion re-enters it and you get `RecursionError`. ## Job two: preserving shared structure Even with no cycle anywhere, the memo changes the shape of the result. Suppose two attributes of an object both reference one shared list. Without a memo, the deep copy would visit that list twice and produce two independent lists — the copy would have a different topology from the original, and an append through one attribute would no longer be visible through the other. With the memo, the second visit returns the same new list, and the copy has the same sharing pattern the original had. Deep copying preserves the shape of the graph, not just its values, and that guarantee is entirely the memo's doing. This is exactly why a nested `copy.deepcopy(child)` written *without* the memo inside a hook is a defect rather than a style nit. Each such call starts a fresh pass with a fresh memo, so aliasing within the child is preserved only locally, shared subgraphs get duplicated once per entry point, and a cycle that passes back out through the parent still runs away. ## A detail worth knowing The memo also holds something that is not an id-to-copy mapping: `deepcopy` keeps the *originals* alive for the duration of the pass by stashing them in a list inside the memo itself. Without that, an original with no other reference could be garbage-collected mid-pass, its `id()` could be reused by a newly allocated object, and the memo would hand out the wrong copy. It is a small piece of defensive machinery that explains why the memo you get in a hook may contain an entry you did not put there and did not expect. ## Using the memo deliberately Because the memo is checked before anything else, you can pre-seed it to force sharing without touching any class. Calling `copy.deepcopy(obj, {id(registry): registry})` maps a specific object to *itself*, so every reference to that registry inside the graph comes through unchanged while everything else is duplicated normally. This is a clean escape hatch for a one-off: a caller that knows one particular collaborator must be shared can say so at the call site instead of adding a permanent `__deepcopy__` to a class. What you should not do is manufacture a memo to reuse across independent copies. It is scoped to one pass by design; reusing it across two calls would make the second copy silently alias objects from the first, which is almost never what the caller meant by "a deep copy".

  • What happens if a `__deepcopy__` registers itself in the memo only after copying its attributes?
    Nothing on a tree, and `RecursionError` on any cycle. The registration exists so the *re-entrant* visit finds the copy already under construction; if it happens after the recursion, the re-entrant visit never sees it. `copy.deepcopy()` does memoize the hook's return value afterwards, but on a cyclic graph the hook never returns. Always assign `memo[id(self)] = new` immediately after allocating.
  • Is it ever acceptable to call `copy.deepcopy(child)` inside a hook without passing the memo?
    Effectively no. A bare call starts an independent pass with its own memo, so a cycle leading back through the parent is not detected, and any object shared between that child and the rest of the graph gets duplicated once per entry point instead of staying shared. The copy then has a different topology from the original, which is the one thing deep copy is supposed to preserve.
  • Why key the memo by `id()` rather than by the objects themselves?
    Because the memo has to hold unhashable objects such as lists and dicts, which cannot be dict keys at all, and because the question is about identity rather than equality — two distinct but equal instances must get two distinct copies. `id()` gives a hashable identity token for any object, and `deepcopy` keeps the originals alive for the pass so those ids cannot be recycled underneath it.

The memo is the visited-set of a graph traversal: without it, a walk over a graph with a cycle never ends and diamonds get expanded twice.

saying these in an interview costs you the question

  • Calls the memo a speed cache that can be skipped
  • Keys the memo by the object instead of `id()`
  • Registers the copy after recursing into children
  • Starts a fresh memo for each nested deepcopy call
  • Thinks recursion is bounded by a depth limit instead
  • Says deep copy always duplicates shared objects twice

context