Python copy versus deepcopy — the copy that is not a backup
A shallow copy is a new box around the same contents.
<!-- hal:authoritative:yaml -->
A shallow copy is a new box around the same contents.
§I — Frame
Tuesday named two comprehensions. Square brackets finish the work; parentheses hand you a plan. Coin: the scan that is not the table. 08-20 named is and ==, __hash__ and __eq__. Coin: the instance that is not a name. That lesson closed by saying two objects can be equal and distinct, and then stopped one page short of the question Ramalho asks next: if you copy an object that contains other objects, do you copy the insides too?
Today's Ops lesson is a census of Azure storage accounts. Standard_GRS puts six copies of your data in two regions and will replicate a delete to all six. Redundancy copies the outer promise; the data inside is shared, mutation and all. The Ops coin is the copy that is not a backup. The language has the same split and demonstrates it in four lines.
config = {"name": "prodlogs01", "tags": {"env": "staging"}}
snapshot = dict(config)
config["tags"]["env"] = "prod"
print(snapshot["tags"]["env"])
It prints prod. dict(config) built a new dict. The new dict holds the same tags object as the old one. You took a snapshot before the change and the snapshot changed. That is a shallow copy, and Ramalho's Chapter 6 heading is the whole lesson: Copies Are Shallow by Default.
§II — Language idiom: one outer box, shared insides
Ramalho, printed p.208: the easiest way to copy a list or most built-in mutable collections is the type's own constructor. list(l1), dict(d1), set(s1). For sequences, l1[:] does the same. Every one of these produces a shallow copy: the outermost container is duplicated, and the copy is filled with references to the same items the original holds. Lutz, printed p.151, says it for dicts and sets, which are not sequences and cannot be sliced: use X.copy(), and note the standard library copy module for anything else.
Example 6-6, printed p.209, is the demonstration worth typing once.
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1)
l1.append(100)
l1[1].remove(55)
print("l1:", l1)
print("l2:", l2)
l2[1] += [33, 22]
l2[2] += (10, 11)
print("l1:", l1)
print("l2:", l2)
l1.append(100) does not touch l2; the outer lists are different objects. l1[1].remove(55) touches both; l1[1] and l2[1] are the same inner list. l2[1] += [33, 22] is an in-place extend on that shared list, so l1 sees 33, 22 too. l2[2] += (10, 11) is different: tuples are immutable, so += builds a new tuple and rebinds l2[2], and l1[2] still holds (7, 8, 9). Ramalho's Figure 6-4 draws the final state: two outer lists, one shared inner list, two unrelated tuples.
Three rules fall out.
Rule one. Shallow is fine when the contents are immutable. A list of ints, a dict of strings, a tuple of frozensets. Copy the box; nothing inside can move. This is most copies, and it is why the default is shallow.
Rule two. Shallow is a shared reference when the contents are mutable. Nested dicts, lists of lists, objects holding lists. The copy is a second name for the insides. Lutz's phrase is exact: a change from one name may impact others.
Rule three. Immutable containers do not make contents immutable. 08-20 already used Ramalho's t1 = (1, 2, [30, 40]): a tuple that compares equal today and unequal tomorrow because its list moved. A shallow copy of that tuple shares the list.
§III — Code worked example
The census in today's Ops lesson takes a row per storage account. Suppose a reviewer wants a before-and-after: capture the rows, run a proposed remediation in memory, and diff. The first draft copies the list.
import copy
from dataclasses import dataclass, field
@dataclass
class AccountRow:
name: str
sku: str
protection: dict = field(default_factory=dict)
rows = [
AccountRow("prodlogs01", "Standard_GRS", {"soft_delete_days": None, "versioning": False}),
AccountRow("devscratch01", "Standard_LRS", {"soft_delete_days": 7, "versioning": False}),
]
before = list(rows)
for row in rows:
if "GRS" in row.sku and row.protection["soft_delete_days"] is None:
row.protection["soft_delete_days"] = 14
row.protection["versioning"] = True
print(before[0].protection)
It prints {'soft_delete_days': 14, 'versioning': True}. before is a new list of the same two AccountRow objects. The loop mutated row.protection in place. The "before" snapshot shows the "after" state. The diff is empty and the reviewer signs off on a change that was never measured.
Fix one is copy.deepcopy.
before = copy.deepcopy(rows)
Ramalho, printed p.211: the copy module provides deepcopy and copy, returning deep and shallow copies of arbitrary objects. deepcopy walks the object graph and duplicates every mutable thing it finds. Example 6-9 on p.212 is the canonical picture: bus1, bus2 = copy.copy(bus1), bus3 = copy.deepcopy(bus1). After bus1.drop('Bill'), Bill is gone from bus2.passengers because bus1.passengers is bus2.passengers. Bill is still on bus3. id(bus1.passengers) == id(bus2.passengers) != id(bus3.passengers). The deep copy is the backup.
Example 6-10 on p.212 answers the objection everyone raises: what about cycles? a = [10, 20]; b = [a, 30]; a.append(b) is a list that contains a list that contains it. deepcopy(a) terminates. The function keeps a memo dict of ids already copied and returns the existing copy when it sees one again. You rarely see the memo, but it is the second parameter of __deepcopy__ and you must pass it through if you write one.
Fix two is a frozen row, which is what the Ops lesson actually ships.
@dataclass(frozen=True)
class FrozenRow:
name: str
sku: str
soft_delete_days: int | None
versioning: bool
def with_protection(row: FrozenRow, days: int) -> FrozenRow:
return FrozenRow(row.name, row.sku, days, True)
rows = [
FrozenRow("prodlogs01", "Standard_GRS", None, False),
FrozenRow("devscratch01", "Standard_LRS", 7, False),
]
before = list(rows)
after = [with_protection(r, 14) if "GRS" in r.sku and r.soft_delete_days is None else r for r in rows]
print(before[0].soft_delete_days, after[0].soft_delete_days)
It prints None 14. The frozen dataclass has no protection dict to mutate and no setter for its fields; with_protection builds a new row. before = list(rows) is still a shallow copy, and that is now fine, because rule one applies: the contents cannot move. You did not need deepcopy because you removed the thing that made shallow dangerous. dataclasses.replace(row, soft_delete_days=14, versioning=True) does the same construction generically.
Prefer fix two when you own the type. Prefer fix one when you do not.
§III.B — Four more rules the copy will break
**Rule four. field(default_factory=list) is the copy problem at class-definition time.** A mutable default argument is one object shared by every instance that does not override it. Ramalho's HauntedBus in the same chapter is the horror story; the dataclass decorator refuses passengers: list = [] outright and makes you write the factory. The factory is a fresh copy per instance.
**Rule five. copy.copy on your own class calls __copy__ if you define it, and deepcopy calls __deepcopy__(self, memo).** Define them when the object holds something that should not be duplicated. A census object with a client attribute should share the client and copy the rows.
class Census:
def __init__(self, client, rows):
self.client = client
self.rows = rows
def __deepcopy__(self, memo):
clone = Census(self.client, copy.deepcopy(self.rows, memo))
memo[id(self)] = clone
return clone
Passing memo into the inner deepcopy and recording self in it is what keeps cycles and shared sub-objects consistent. Skip either and you have written a deepcopy that is neither deep nor a copy.
Rule six. Deep copies are expensive and sometimes impossible. A row holding an open socket, a lock, a generator, or a database cursor will raise or produce nonsense under deepcopy. That is not a bug in copy. It is the object telling you it is a handle, not a value. Handles get shared; values get copied. __deepcopy__ is where you say which is which.
**Rule seven. == after a copy tells you nothing about sharing.** 08-20's coin. before == rows is True for both the shallow and the deep copy right after copying. Only before[0].protection is rows[0].protection tells you whether a later mutation will leak. If the test suite asserts equality on a snapshot, it is testing the copy, not the backup.
§III.C — The defensive copy at the boundary
Look again at Ramalho's Bus.__init__ on printed p.211. It does not write self.passengers = passengers. It writes self.passengers = list(passengers). That one call is the most common correct use of a shallow copy in production code: the constructor takes whatever iterable the caller hands it and keeps its own list, so a caller who later appends to their list does not silently add a passenger to the bus. The copy is shallow, and that is enough, because passenger names are strings and strings cannot move. Rule one, applied at a boundary.
Drop the list() and you have written the other bus in the same chapter, the one Ramalho calls TwilightBus, where dropping a passenger from the bus removes a name from the caller's team roster because both names point at the same list. The caller never asked for that. The class made a shared reference out of an argument and called it an attribute.
The census tool from today's Ops lesson has the same seam.
class Census:
def __init__(self, client, rows):
self.client = client
self.rows = list(rows)
def findings(self):
return [r for r in self.rows if "GRS" in r.sku and r.soft_delete_days is None]
list(rows) makes the census own its rows. If the rows are FrozenRow, the shallow copy is a complete defense; nothing inside can change, so a second name for the same row is harmless. If the rows were the mutable AccountRow from §III, the defense would be partial: the caller could still reach into row.protection through their own reference and the census would see it. That is the decision tree in one sentence. At a boundary, list(x) protects you from the caller re-binding or extending the container. Only deepcopy or immutability protects you from the caller mutating what is inside.
Two more boundary shapes worth naming. A function that receives a dict and wants to return a modified version should build a new one, {**d, "key": value} or d | {"key": value}, rather than assign into d and return it; the caller's dict is their backup and you are not entitled to overwrite it. And a function that receives a list and wants to hand back a sorted view should call sorted(x), which allocates, not x.sort(), which mutates in place and returns None. Both are shallow copies chosen because the alternative is mutating an argument you do not own.
Finally, the thing that looks like a copy and is not one at all. types.MappingProxyType(d) returns a read-only view of d. You cannot assign through the proxy, but changes to d show through it instantly. It is a window, not a photograph. Use it to publish a dict you keep mutating without letting readers mutate it back; do not use it as a snapshot.
from types import MappingProxyType
live = {"soft_delete_days": None}
view = MappingProxyType(live)
live["soft_delete_days"] = 14
print(view["soft_delete_days"])
It prints 14. The proxy is the readable secondary endpoint on an RA-GRS account: you can read it, you cannot write it, and it will show you the delete the moment the primary takes one.
§IV — Connection to today's Ops lesson
The Ops tool prints sku.name next to delete_retention_policy. Standard_GRS is dict(config): a second outer object in a second region, holding references to the same blobs, so a delete on one is a delete on all. Soft delete and versioning are deepcopy: a real second object that stays put when the first moves. The census finding, geo-copy on and retention None, is before[0].protection printing the after state.
The Ops row is frozen=True for the reason §III gave. A census that can be mutated after capture is bus2. The Ops lesson wants bus3 without paying for deepcopy on every row, so it makes the contents immutable and lets list(rows) be safe.
§V — Prior-lesson reach
09-01 said square brackets finish the work. Today adds: they finish the outer work. [r for r in rows] is a new list of the same rows, exactly like list(rows). A comprehension is a shallow copy with a filter. If the rows are mutable, the comprehension is not a snapshot either.
08-20 said == is value and is is identity, and that equal objects can diverge when one holds a mutable. Today is the operational consequence: to keep two objects from diverging, either copy the mutable insides (deepcopy) or remove them (frozen). Identity is the diagnostic; copy semantics are the treatment.
08-23 said field(repr=False) hides the secret from the log. Today's field(default_factory=dict) is the other field argument you will type every week, and it exists for the same chapter's reason: one shared mutable default is a copy that is not a backup for every instance at once.
§VI — Closing
list(x), x[:], x.copy(), dict(x): new box, same contents. copy.copy(x): same thing for any object. copy.deepcopy(x): new box, new contents, memo for cycles, __deepcopy__ to say what stays shared. frozen=True: no copy needed because nothing inside can move.
Ramalho: copies are shallow by default. Lutz: a change from one name may impact others. Azure: six replicas will honor the delete. The sentence is the same length in all three. The copy is not a backup.
Examine the next before = list(state) in a review. Ask what is inside state. If any of it has a .append or a [k] =, the snapshot is already spent.
Related
- Prior arc: the scan that is not the table (2026-09-01)
- Language hub: Cross-References/dev-languages/Python
- Grounding: Ramalho — Copies Are Shallow by Default; Deep and Shallow Copies of Arbitrary Objects (Ch.6, pp. 208-213) · Lutz — Shared References and In-Place Changes (Ch.6, pp. 149-151)