Back to Blog
Python

Implementing a Custom Deepcopy in Python

Implement __deepcopy__ in Python to control how copy.deepcopy handles your objects, including memo handling, shared resources, and circular references.

deepcopycopy module__deepcopy__object copyingpython classes
Illustration of a Python object being duplicated with a custom deepcopy method controlling which internal resources are copied.

copy.deepcopy normally knows how to clone an object graph, but classes that hold resources or need to share selected attributes can implement __deepcopy__ to take control. This article explains how the hook works, how to handle memo, and when a custom implementation is worth the maintenance cost.

What deepcopy Does by Default

copy.deepcopy recursively copies an object and every object it references. For a plain class instance, that means copying the __dict__ (or __slots__ values) and then recursing into each attribute. The result is a fully independent object graph: mutating a nested attribute on the copy does not affect the original.

The default behavior works well for data-oriented classes. It fails when an object holds resources that cannot be copied by value—a file handle, a database connection, a thread, a lock, or a reference to a singleton. It also fails when you want the copy to share certain attributes while duplicating others.

That is why __deepcopy__ exists: it lets the class author decide what copy.deepcopy produces for that class.

When the Default Behavior Fails

Consider a class that opens a file in its constructor:

class LogWriter: def __init__(self, path): self.path = path self._handle = open(path, 'a')

Calling copy.deepcopy on an instance of this class will attempt to copy the open file object. File objects are not copyable, so deepcopy raises a TypeError:

TypeError: cannot pickle '_io.TextIOWrapper' object

The exact exception depends on the Python version and the type of the underlying resource, but the problem is the same: deepcopy tries to duplicate something that cannot be duplicated.

A custom __deepcopy__ lets you decide what happens instead. In this case, the copy should open a new file handle to the same path, or share the existing handle, depending on the semantics you want.

Implementing __deepcopy__

The method signature is fixed:

def __deepcopy__(self, memo): ...

memo is a dictionary that copy.deepcopy uses to track objects that have already been copied. It maps id(original) to the corresponding copy. Your implementation must accept it and pass it through to any recursive copy.deepcopy calls you make.

The method must return the new object. For a simple class, you can create the instance without calling __init__, register it in memo, and then populate its attributes:

import copy class Config: def __init__(self, values): self.values = values def __deepcopy__(self, memo): new = Config.__new__(Config) memo[id(self)] = new new.values = copy.deepcopy(self.values, memo) return new

Registering the new object before recursing is what prevents infinite recursion when the object graph contains cycles. If you instead call the constructor with a deepcopy of self.values before registering new, a value that references the original Config object can trigger recursive copying before deepcopy knows the duplicate already exists.

Handling the memo Parameter Correctly

The memo parameter is the part of __deepcopy__ that is easiest to get wrong. If you ignore it, you break two guarantees that copy.deepcopy normally provides.

The first guarantee is identity preservation. If the same object is referenced from two places in the graph, deepcopy should produce a single copy that both references point to. Without registering in memo, each recursive call would create a separate copy, and the copied graph would no longer mirror the original structure.

The second guarantee is termination. For a circular structure, such as a tree where a child holds a reference to its parent, an unregistered copy will recurse forever:

import copy class Node: def __init__(self): self.parent = None self.children = [] def __deepcopy__(self, memo): new = Node() # Register before recursing into attributes. memo[id(self)] = new new.parent = copy.deepcopy(self.parent, memo) new.children = copy.deepcopy(self.children, memo) return new

Notice this version calls Node() because Node.__init__ needs no required arguments. It registers the new object before copying attributes. If the memo[id(self)] = new line is moved after the recursive calls, the copy of self.parent will eventually hit the original self again and start a new copy, which will again copy its parent, and so on until the recursion limit is reached.

The rule is simple: register the new object in memo immediately after creating it, before copying any attribute that could reference the original object.

A Practical Example: Copying an Object With a Resource

A common real-world case is a class that holds both plain data and a resource that should not be duplicated. The custom __deepcopy__ copies the data and shares or recreates the resource:

import copy class Session: def __init__(self, user_id, connection=None): self.user_id = user_id self.connection = connection def connect(self, connection): self.connection = connection def __deepcopy__(self, memo): new = Session.__new__(Session) memo[id(self)] = new new.user_id = copy.deepcopy(self.user_id, memo) new.connection = self.connection # intentionally shared return new

Here the copy shares the connection object while duplicating the user_id value. If the connection should be re-established rather than shared, you would create a new connection inside __deepcopy__ instead of passing the old one through.

The decision of what to copy and what to share is the entire point of writing a custom implementation. There is no general rule that fits every class; the method exists precisely so that the class author can encode that decision.

Performance and Identity Considerations

A custom __deepcopy__ can also be a performance optimization, though that should not be the primary reason to write one. When an object contains a large attribute that is known to be immutable, such as a frozen configuration or a cached compiled pattern, copying it with copy.deepcopy wastes time and memory. Returning it directly from __deepcopy__ avoids that cost:

import copy import re class CompiledQuery: def __init__(self, source, pattern): self.source = source self._compiled = re.compile(pattern) def __deepcopy__(self, memo): new = CompiledQuery.__new__(CompiledQuery) memo[id(self)] = new new.source = copy.deepcopy(self.source, memo) new._compiled = self._compiled # shared, not deep-copied return new

The _compiled attribute is a compiled regular expression, which is immutable and safe to share between copies. If the source string is also immutable and you do not need to copy it, you can share it too. The same reasoning applies to any immutable cached value: sharing it avoids the overhead of duplicating it.

The tradeoff is maintainability. A custom __deepcopy__ must be kept in sync with the class's fields. When a new attribute is added to the class, the method must be updated to handle it; otherwise, the new attribute is silently omitted from copies. This is a real cost, and it means you should write __deepcopy__ only when the default behavior is genuinely wrong for the class.

Common Mistakes and Edge Cases

The most frequent mistake is forgetting to register the new object in memo before recursing. The result is either infinite recursion or duplicated objects in the copied graph, depending on whether the graph contains cycles.

Another mistake is returning the original object from __deepcopy__. For an immutable class this can be correct, but for a mutable class it breaks the contract of deepcopy: mutating the copy would mutate the original. If you intend to share an attribute, share it inside a new instance; do not return the original instance itself.

A subtler issue is __slots__. If your class uses __slots__ instead of __dict__, you must copy the slot values manually, because there is no __dict__ to copy:

import copy class Point: __slots__ = ('x', 'y') def __init__(self, x, y): self.x = x self.y = y def __deepcopy__(self, memo): new = Point.__new__(Point) memo[id(self)] = new new.x = copy.deepcopy(self.x, memo) new.y = copy.deepcopy(self.y, memo) return new

Finally, __deepcopy__ is only used by copy.deepcopy. The pickle module and the copy.copy function use different protocols. If your class also needs to support pickling, __deepcopy__ alone is not enough.

Choosing Between __deepcopy__, __copy__, and __reduce__

copy.copy uses __copy__ when it is defined. It produces a shallow copy, duplicating the object itself but sharing the referenced objects. If a class needs both shallow and deep copy behavior, implement both methods.

__reduce__ is used by pickle and also by copy.deepcopy as a fallback when __deepcopy__ is not defined. If you already implement __reduce__ for pickling and do not define __deepcopy__, deepcopy will use it, and the result may or may not match what you want. For example, a __reduce__ that reconstructs the object from a database ID will produce a deepcopy that shares nothing with the original, which is usually fine, but it also means the copy is not created from the original's current in-memory state.

The practical guidance is: implement __deepcopy__ when you need precise control over how copy.deepcopy treats your class. Implement __copy__ when shallow copy behavior also needs to be customized. Rely on __reduce__ only when pickling is the primary requirement and deepcopy behavior is acceptable as a side effect.

Python Custom Deepcopy: Control copy.deepcopy with __deepcopy__ | RYUSLOG DEV