Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·25 MIN

Copying: assignment copies nothing, copy copies one level, deepcopy copies the whole graph

Three operations people confuse with each other, because on a flat list all three look the same. The difference shows up only on nesting — and that is also where it turns out a deep copy does not “copy everything”: a shared object stays shared, a cycle stays a cycle, and __init__ is never called.

Full technical treatment

TL;DR

Three operations on one structure give three different results, and on a flat list the difference is invisible — which is exactly why they get confused. b = a copies nothing: it is a second name for the same object, and the module reference says so outright — "Assignment statements in Python do not copy objects, they create bindings between a target and an object." copy.copy(a) builds a new top object but puts the same nested objects into it. copy.deepcopy(a) reaches the nested ones too.

Hence the main consequence: the difference shows only when you change something nested. A write to b[0] after a shallow copy shows up in a. And two cases nobody expects: __init__ is never called — neither by the shallow copy nor by the deep one, so invariants the constructor establishes are not re-established; and a shallow copy of a list containing itself contains the original, not itself: b[0] is a is True, b[0] is b is False, so the cyclic structure is gone.

Beyond that is what separates knowing from having read. A deep copy does not "copy everything in sight": it builds a copy of the graph through the copying protocol, remembering the objects it has already copied in a memo table. Two properties follow at once: a cycle does not send the copy into infinity, and an object that sat under two keys is still one object in the copy. The cost: on a list of a thousand three-level records the shallow copy takes 2.35 µs, the deep one 4252 µs (3.13.7). That is ×1809, and it grows with the number of objects inside, not with the volume of data.

Where to start
Before this lesson it is enough to understand
  • that a list or a dict can be changed in place, without creating a new object;
  • that a value can sit inside another value — a list inside a list, a dict inside a dict;
  • that two variables can point at the same object, in which case a change is visible through both.
You do not need to know in advance
  • how a shallow copy differs from a deep one, what the copying protocol and an object graph are;
  • memo, __reduce_ex__, __deepcopy__, atomic types, copy.replace.

Base: three operations in four lines

The whole lesson grows out of four lines of code. They are worth writing out in full and looking at once.

PYTHON
import copy
 
a = [[1, 2], [3]]         # a list with other lists inside it
b = a                     # assignment
c = copy.copy(a)          # a shallow copy
d = copy.deepcopy(a)      # a deep copy

As long as nothing has been changed, all four names show the same contents, and == between any two of them is True. The difference appears at exactly one moment — when you change a nested object rather than the top one:

PYTHON
a[0].append(99)           # change the list that sits INSIDE
 
b[0]                      # [1, 2, 99] — b is a; no second object was ever made
c[0]                      # [1, 2, 99] — the top list is new, the nested one is not
d[0]                      # [1, 2]     — the deep copy has a nested list of its own

Three different answers to one change. Read them like this:

  • b = a creates no object at all: it is a second name for the same list;
  • c is a new top-level list whose nested lists are the very ones a has;
  • d is a new top-level list whose nested lists are new as well.

And here is the question this lesson is about: on a flat list these three operations are indistinguishable — so how deep does each of them actually reach? The answer at base level: assignment reaches nowhere, a shallow copy reaches the first level, a deep copy goes past it.

That is already enough to answer the basic interview question. Everything below is about where "past it" ends: what a deep copy does with an object it meets twice, what happens to a structure that refers to itself, what neither operation copies at all, and why the constructor is not called even once.

Mechanism 1: assignment does not copy

language contractLanguage guarantee: assignment binds a name to the same object. A copy begins only where one is asked for explicitly.

This is the first place the confusion starts, and also the simplest. b = a creates no new object at all — there is now a second name for the same one.

Assignment statements in Python do not copy objects, they create bindings between a target and an object. For collections that are mutable or contain mutable items, a copy is sometimes needed so one can change one copy without changing the other.

The copy module, opening paragraph

One line proves it:

PYTHON
a = [1, 2, 3]
b = a
b.append(4)
print(a)        # [1, 2, 3, 4]
print(b is a)   # True

This gives the rule everything else reduces to: "copy or not a copy" is always a question about is, never about ==. Equality distinguishes nothing here: the original, the shallow copy and the deep copy all compare == as True.

Mechanism 2: one level — and what that means

The two definitions in the documentation differ by exactly one word, and that word should be read literally.

A shallow copy constructs a new compound object and then (to the extent possible) inserts references into it to the objects found in the original.

The copy module, on the difference

A deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original.

The same page

References versus copies. Everything else follows.

Switch the picture to "a shared object": in the deep copy inner turns out to be one object for two cells. Then to "a cycle": the shallow copy's single cell points at the original rather than at itself.

There are four ways to make a shallow copy, and all four are the same thing:

PYTHON
b = a[:]
b = list(a)
b = a.copy()
b = copy.copy(a)

They differ by under seven percent on the same list of a thousand records — that is, they do not differ. The fifth way, often written out of habit, costs 5.8 times more than the other four:

PYTHON
b = [x for x in a]      # 13.7 µs against 2.3–2.5 µs for the other four

The reason is not the copying: a comprehension runs a separate loop step per element, while a[:] and list(a) copy the array of pointers in one go.

The classic trap comes from the same place — list repetition:

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

The outer * 3 did not create three rows. It created three references to one row — exactly what a shallow copy does, just written shorter.

Mechanism 3: a deep copy builds a copy of the graph, not one reference at a time

This is the point where most explanations get it wrong. "A deep copy copies the whole tree", "a deep copy copies everything" — wrong, and wrong twice over: first, the structure need not have the shape of a tree at all; second, not every reference it meets gets copied. More precisely: a deep copy builds a copy of the object graph, asking each object it meets how to copy it and remembering what it has already copied. The first half is the copying protocol: every type has a way of being copied, and an object may intercept that itself. The second half is a table deepcopy consults for every object.

Two properties follow straight from that wording, and follow from "copies the whole tree" not at all: a shared object stays shared in the copy, and a cycle does not send the copying into infinity.

Because deep copy copies everything it may copy too much, such as data which is intended to be shared between copies.

The copy module, on the two problems of deep copying

The fix is named on the same page — "keeping a memo dictionary of objects already copied during the current copying pass".

What follows from it in practice:

PYTHON
shared = {"config": 1}
orig = {"a": shared, "b": shared}
deep = copy.deepcopy(orig)
 
print(orig["a"] is orig["b"])       # True — one object under two keys
print(deep["a"] is deep["b"])       # True — and in the copy it is ONE too
print(deep["a"] is orig["a"])       # False — but a different one

The structure is reproduced, not multiplied. Two separate deep copies share nothing with each other: the table lives for one pass and is created afresh on the next call.

That same table can be filled in advance — which is how you forbid copying a particular object:

PYTHON
memo = {id(shared): shared}
kept = copy.deepcopy(orig, memo)
print(kept["a"] is shared)          # True

This is the way to say "copy everything except this one" without touching the classes: the second parameter of deepcopy is open for exactly that. The other way is a __deepcopy__ hook, covered below.

And the same memo is why a cycle survives copying:

PYTHON
a = []
a.append(a)
 
s = copy.copy(a)
d = copy.deepcopy(a)
 
print(s[0] is a, s[0] is s)     # True  False
print(d[0] is a, d[0] is d)     # False True

The shallow copy of a list that contained itself does not contain itself: its single cell points at the original. The cyclic structure is gone, even though it was the very thing being copied. The deep copy preserved the cycle.

Mechanism 4: what neither of them copies

The list is spelled out in the documentation.

This module does not copy types like module, method, stack trace, stack frame, file, socket, window, or any similar types. It does "copy" functions and classes (shallow and deeply), by returning the original object unchanged; this is compatible with the way these are treated by the pickle module.

The copy module, on the boundaries

And here is the divergence that looks like an interpreter bug every time:

objectcopy.copy returned the samedeepcopy returned the same
int, str, range, a function, a typeyesyes
a tuple of immutablesyesyes
a tuple with a list insideyesno
frozensetyesno
sliceyesno

Immutability has nothing to do with it. Lib/copy.py holds two different sets of atomic types: copy's contains tuple, frozenset, slice and super, deepcopy's does not. frozenset and slice therefore take the long road through __reduce_ex__, and deepcopy builds a new object. A tuple is a separate case, covered right below. The result is identical on 3.11, 3.12, 3.13 and 3.14.7.

A tuple of immutables is a separate case, and not one about the sets of types: _deepcopy_tuple copies the elements, compares each with the original and, if none of them changed, returns the original tuple.

Mechanism 5: __init__ is not called

This is the consequence people learn about late and expensively.

PYTHON
class Connection:
    def __init__(self, dsn):
        self.dsn = dsn
        self.socket = open_socket(dsn)
 
c1 = Connection("postgres://…")
c2 = copy.copy(c1)
c3 = copy.deepcopy(c1)

Neither copy calls __init__ — zero calls for two copies, verified with a counter. Both go through __reduce_ex__ and restore __dict__ directly. Which means:

  • argument checks that live in the constructor do not run;
  • resources the constructor opens are not opened — they are copied like any other field. The shallow copy gets the very same socket. The deep one makes no exception for it: it tries to copy the socket as an ordinary field, and on a real socket that usually ends in an error. This is exactly why the documentation lists such types separately.

If an object holds something that must not be copied, the hook is written by hand:

PYTHON
class Connection:
    def __deepcopy__(self, memo):
        new = Connection.__new__(Connection)
        memo[id(self)] = new                       # BEFORE walking the fields
        new.dsn = copy.deepcopy(self.dsn, memo)    # memo is passed along
        new.socket = open_socket(new.dsn)
        return new

Two lines here are non-obvious, and both are documented. Writing into memo before walking the fields — otherwise an object referring to itself recurses forever. And passing memo as the second argument: "If the __deepcopy__ implementation needs to make a deep copy of a component, it should call the deepcopy() function with the component as first argument and the memo dictionary as second argument." Without that second argument, the very thing the table exists for falls apart.

Copying is not restoring an invariant

Hence a practical rule worth saying in full: a copy is a snapshot of fields, not an object built afresh. Everything the constructor was responsible for — validating the dsn, the open socket, the registered handler — is in the copy either missing or shared with the original.

Which is why deepcopy has neighbours that often turn out to be what you actually wanted. They are not replacements — they solve different problems, and the choice is made by the problem, not by the price:

what you needwhat to use
the same type with a couple of fields changeddataclasses.replace(obj, field=value) — it calls __init__
an object rebuilt from datayour own factory or constructor
no copy needed at allimmutable values: there is nothing to copy
a copy that outlives the process or crosses a networkserialisation; that is no longer copying but another format

deepcopy stays where what you need is exactly a snapshot of the graph with all its internal links — and there it has no substitute.

Deeper: what it costs

measured observationbench/copy/cost.py, CPython 3.13.7. Absolute times depend on the machine and the shape of the graph; the ratio is what matters.

What follows from the picture is not "use pickle" but two rules.

A record in the measurement is {"id": i, "tags": ["a", "b"], "meta": {"x": i}}: three levels and four objects per record, that is, four thousand objects for a thousand records. That is where the ×1809 comes from — which makes it a multiplier for that shape of data, not a property of deep copying as such: on another shape it will be another number. What travels is not the multiplier but the dependency it came from.

First: the cost of a deep copy grows with the number of objects, not the volume of data. Ten thousand numbers in a list copy in 2.3 ms; ten thousand one-element lists take 10 ms. The same count of numbers, nearly the same volume; the second case adds ten thousand lists that have to be walked and built anew. The ints themselves are not copied at all — they are atomic and come back as they are.

Second: a deep copy is recursive. At a nesting depth of 498 with the default sys.getrecursionlimit() == 1000 it fails with RecursionError — that is, it burns two stack frames per level. A five-hundred-level tree cannot be copied, and you find out in production.

A word on the fast substitutes. A pickle round trip is 5.8 times faster than the deep copy — a different comparison from "comprehension against slice" above; the ratio merely coincides. It preserves both object sharing and cycles, but requires the object to be picklable, and a socket, a file or a closure is not. json is 2.7 times faster and changes types silently: {1: (2, 3)} comes back as {'1': [2, 3]}, and on a cycle it raises ValueError. Copying by hand along a known shape is 14.2 times faster precisely because the shape is written into it by hand — and it holds until the first new field.

How to answer in an interview

Short answer: assignment copies nothing — it is a second name for the same object; copy.copy builds a new top-level object and leaves the nested ones shared; copy.deepcopy reaches the nested ones too. The difference shows only when you change something nested: after a shallow copy the change is visible from both sides, after a deep one it is not.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

The precise wording for a deep copy is not "it copies the whole tree" but "it builds a copy of the object graph through the copying protocol, remembering what it has already copied". Two properties follow, and they are the reason for being precise: a cycle does not send the copying into infinity, and an object that sat under two keys is still one object in the copy — deep["a"] is deep["b"] is True.

And three things that are rarely named yet cost the most: __init__ is not called even once; deepcopy is recursive and bounded by the stack limit; the cost is counted in objects, not bytes.

Next they ask

Next they ask

The structure has a cycle: an object refers to itself. Will deepcopy loop forever?

Short answer

No. deepcopy copies a graph, not each reference separately: it remembers what it has already copied, so a cycle closes onto the copy, and two different paths to one object give one object in the copy rather than two.

Next they ask

Does copying call __init__?

Short answer

No, and this is a regular source of surprises: the object is assembled bypassing the constructor, so anything __init__ did beyond assigning fields does not happen on a copy.

Next they ask

Is there anything neither a shallow nor a deep copy will copy?

Short answer

Yes, and the lesson gives it its own section: part of an object's contents is not carried over by copying at all, whichever of the two you choose.

Common misconceptions

Claim

The slice a[:] and copy.copy(a) are interchangeable

Actually

For a list they are the same thing: Lib/copy.py sends a list straight to list.copy. The measurement agrees: a[:], list(a), a.copy() and copy.copy(a) differ by under 7% on a list of a thousand elements. The difference appears on YOUR OWN classes: copy.copy looks for __copy__ and __reduce_ex__, while an arbitrary object has no slice at all.

Claim

A deep copy copies each reference separately

Actually

Measured: if one dictionary sat under two keys, in the deep copy it is still ONE — deep["a"] is deep["b"] is True. What gets copied is the graph, not the walk, and what holds that together is the memo table the documentation names outright. The flip side is the cycle: after deepcopy, a list containing itself contains ITSELF, not the original.

Claim

A shallow copy of a cyclic structure gives the same cyclic structure

Actually

It does not. a = []; a.append(a), then b = copy.copy(a) — and b[0] is a is True, b[0] is b is False. The copy's single cell points at the ORIGINAL; the copy itself is not cyclic. This follows directly from the definition "inserts references into it to the objects found in the original" — merely applied to the case where the object found IS the original.

Claim

Copying an object goes through __init__

Actually

Zero calls for two copies, verified with a counter in the constructor. Both copy and deepcopy go through __reduce_ex__ and restore __dict__ directly. Which is why a copy of an object holding an open socket gets the same socket (shallow) or an attempt to copy the socket (deep), rather than a new connection.

Claim

deepcopy of an immutable object always returns that object

Actually

Not for frozenset and slice: copy.copy returns the same object, copy.deepcopy builds a new one. The cause is not immutability but the fact that Lib/copy.py holds TWO different sets of atomic types, and these types are only in copy's. Identical on 3.11–3.14. A tuple behaves the opposite way and for a different reason: _deepcopy_tuple compares the copied elements with the originals and, if none changed, returns the original tuple.

Version history

VersionChangeWhat this means for your code
3.11The lesson's baseline. Everything discussed above — the depth of copying, the preservation of sharing and cycles, the divergence on frozenset and slice, the absence of an __init__ call — is already the same on 3.11 as on 3.14. Verified by running one and the same script on four versions.
3.13copy.replace(obj, **changes) is added — the module's third function, which most people do not know about. It does not copy a tree: it creates a new object of the same type with replaced fields, and works only with namedtuple, dataclasses and classes that define __replace__. Where you want "the same thing but with one field changed", it replaces the deepcopy-plus-assignment pair.
3.14The fork in Lib/copy.py is rewritten: instead of a _copy_dispatch dictionary there are membership checks against the sets _copy_atomic_types and _copy_builtin_containers. Behaviour did not change in a single measured line; the only reason to know is not to be surprised when the source looks different across versions.

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

The same dict sits under two keys. What does this code print?
import copy

inner = {"n": 1}
outer = {"a": inner, "b": inner}
deep = copy.deepcopy(outer)
shallow = copy.copy(outer)

print(deep["a"] is deep["b"])
print(deep["a"] is inner)
print(shallow["a"] is inner)

Practice · estimate

A tree of fifty dicts with lists inside: copy.copy against copy.deepcopy. How many times more expensive is the deep copy?
times

Check yourself

Question 1 of 5

orig = {'a': shared, 'b': shared}, where shared is one dictionary. How many dictionary objects are there inside copy.deepcopy(orig)?

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Sources & further reading

6 SOURCES

  1. The copy module — shallow and deep copy operationsOfficial documentation. The primary source for every definition in this lesson. On assignment: «Assignment statements in Python do not copy objects, they create bindings between a target and an object». On the shallow copy: «A shallow copy constructs a new compound object and then (to the extent possible) inserts references into it to the objects found in the original». On the deep one: «A deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original».https://docs.python.org/3.14/library/copy.html
  2. The copy module — the memo table and what deepcopy does not copyOfficial documentation. Where the sections on sharing and on cycles come from. The documentation names both problems outright: «Because deep copy copies everything it may copy too much, such as data which is intended to be shared between copies», and immediately says what solves them: «keeping a memo dictionary of objects already copied during the current copying pass». The same page lists what the module does not copy at all: «This module does not copy types like module, method, stack trace, stack frame, file, socket, window, or any similar types».https://docs.python.org/3.14/library/copy.html
  3. Lib/copy.py — two different sets of atomic typesCPython source code. The place that explains a divergence which otherwise looks like a bug. copy has its own set, _copy_atomic_types, containing tuple, frozenset, slice and super; deepcopy has another one, _atomic_types, and those four are not in it. Hence copy.copy(frozenset(...)) returns the same object while copy.deepcopy(frozenset(...)) builds a new one, even though frozenset is immutable either way. On 3.13 the same fork is written differently (a _copy_dispatch dictionary and a loop over types) and the result is identical — checked by running on 3.11, 3.12, 3.13 and 3.14.7. CPython tag 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Lib/copy.py
  4. Lib/copy.py — _deepcopy_tuple and the lookup order in deepcopyCPython source code. _deepcopy_tuple explains why a deep copy of a tuple of immutables returns the SAME tuple: if no element changed after copying, the original is returned. The same file gives deepcopy's handler lookup order: atomic type, type table, subclass of type, __deepcopy__, copyreg.dispatch_table, __reduce_ex__(4), __reduce__. CPython tag 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Lib/copy.py
  5. The copy module — the __copy__ and __deepcopy__ protocolOfficial documentation. The specification of both hooks, and the requirement on how __deepcopy__ must be written: «If the __deepcopy__ implementation needs to make a deep copy of a component, it should call the deepcopy() function with the component as first argument and the memo dictionary as second argument». Drop the second argument and the very thing memo exists for falls apart.https://docs.python.org/3.14/library/copy.html
  6. The copy module — copy.replace, added in 3.13Official documentation. The module's third function, which most people do not know about: «Creates a new object of the same type as obj, replacing fields with values from changes», marked «Added in version 3.13». It works only with namedtuple, dataclasses and classes that define __replace__.https://docs.python.org/3.14/library/copy.html