TRENDING
Galvanized steel guardrail bolted to wooden posts along the edge of a bridge approach, with a grassy verge and a gravel road beside it
October 1, 2026
How to Enforce Guardrails on AI-Generated Terraform With Open Policy Agent and Rego
Microscope die shot of an AMD EPYC 7702 engineering sample I/O die, its circuit blocks glowing in teal, gold and violet
October 1, 2026
AMD Agrees to Buy Fei-Fei Li’s World Labs for $8.2 Billion to Steer Its Chip Roadmap
A silver signet ring engraved with a coat of arms between two sticks of red sealing wax on a grey surface
October 1, 2026
How to Build a Merkle Tree Certificate Issuer in Python to Keep Post-Quantum Certificates Small
Brass swing-bar door lock, a secondary latch, mounted on a hotel room door
October 1, 2026
Cloudflare’s Post-Quantum Visibility Turns Quantum Readiness Into a Per-Hop Audit
A seven-spot ladybird with black spots on its orange shell climbs a green plant stem
October 1, 2026
OpenAI Launches Dots, Always-On Agents, and Says It Is Still Fixing Known Vulnerabilities
01 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Faint white watermark of a crown above an oval emblem showing through blue paper, a design that stays invisible until light passes through the sheet
How to Detect and Strip Invisible Unicode in Python to Stop ASCII Smuggling and Trojan Source
September 30, 2026
A small white wooden toll booth with a Pay Point sign and a fare board at Penmaenpool Toll Bridge, with orange traffic cones on the bridge deck
Two Cloudflare Agent Billing Betas Turn Web Monetization Into a Question of Who Holds the Meter
September 30, 2026
Eight silver hex keys of graduated sizes fanned out on a steel ring against a dark green surface
Attackers Exploit a Hex-Encoding Bypass in Cisco SD-WAN Manager, and CISA Sets an October 3 Deadline
September 30, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 216 Posts
News 218 Posts
Learning Hub 188 Posts
Home/Learning Hub/How to Deep Copy Python Objects and Avoid Shared-State Bugs
Learning Hub

How to Deep Copy Python Objects and Avoid Shared-State Bugs

Learn why Python's assignment and .copy() silently share nested state, and how copy.deepcopy, memo dicts, and custom __deepcopy__ hooks fix it for good.

September 28, 2026 18 Min Read
16

If you have ever handed two different callers what you thought were two separate lists, dictionaries, or objects, and watched a change in one mysteriously show up in the other, you have run into Python’s shallow copy trap. It is one of the most common sources of “impossible” bugs in real Python code: a shopping cart that shares items with someone else’s cart, a config template that mutates itself, a game state where one player’s inventory bleeds into another’s. In this tutorial you will reproduce that exact class of bug on purpose, watch the obvious-looking fixes fail one at a time, and then build a correct, fully tested fix using Python’s copy module, including the two cases where copy.deepcopy() alone still is not enough: circular references and objects that hold resources like locks.

Table Of Content

  • Prerequisites
  • What “copying” actually means in Python
  • Step 1: Reproduce the bug: assignment does not copy
  • Step 2: The almost-fix: why a shallow copy is not enough
  • Step 3: The real fix: copy.deepcopy()
  • Step 4: How much does deep copying actually cost?
  • Step 5: The cycle problem: why deepcopy needs a memo dictionary
  • Step 6: When deepcopy cannot help you: writing a custom __deepcopy__
  • Step 7: The cousin bug: mutable default arguments
  • Step 8: Put it all together and verify it with pytest
  • Common mistakes and gotchas
  • How to confirm everything works end to end
  • Next steps

By the end you will understand the difference between assignment, a shallow copy, and a deep copy at the level of actual object identity (not just as a rule you memorized), you will have personally reproduced five distinct real bugs and their fixes, and you will have a small, pytest-verified module you can adapt to your own code.

Prerequisites

  • Python 3.10 or later (this tutorial was built and tested on 3.13.14). Everything here is the standard library; no third-party packages are required except pytest for the final verification step.
  • Basic familiarity with Python’s core data types: lists, dictionaries, tuples, and simple classes.
  • No prior knowledge of the copy module is assumed. Every term is defined before it is used.

What “copying” actually means in Python

Python’s own documentation for the copy module states the rule plainly: “Assignment statements in Python do not copy objects, they create bindings between a target and an object.” That single sentence explains almost every bug in this tutorial. When you write b = a, you are not creating a second list or dictionary. You are giving a second name to the exact same object that already exists in memory. Both names point at the same thing, so a change made through one name is visible through the other, because there was only ever one object to begin with.

This only matters for mutable objects: lists, dictionaries, sets, and most class instances, anything that can be changed in place after it is created. Immutable objects like integers, strings, and tuples cannot be changed in place at all, so sharing a reference to one is completely safe. You will see that distinction matter directly in Step 4, when a tuple survives a trip through copy.deepcopy() untouched precisely because there is no way to accidentally mutate it.

Python’s documentation draws a further, more specific distinction once you actually want independent copies of a mutable object:

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. A deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original.

Read that twice, because the phrase “inserts references” in the shallow-copy sentence is the entire trap. A shallow copy gives you a new outer container, but everything inside that container is still the same shared object it was before. You will watch that go wrong for real in Step 2.

Step 1: Reproduce the bug: assignment does not copy

Start with a template dictionary that a game engine hands out as the starting state for every new player.

DEFAULT_PLAYER_STATE = {
    "health": 100,
    "inventory": [],
    "buffs": {"speed": 0, "strength": 0},
}


def new_player_v1():
    """BUGGY: hands every caller the exact same dict object."""
    return DEFAULT_PLAYER_STATE


if __name__ == "__main__":
    alice = new_player_v1()
    bob = new_player_v1()

    print("id(alice) ==", id(alice))
    print("id(bob)   ==", id(bob))
    print("alice is bob:", alice is bob)
    print("alice is DEFAULT_PLAYER_STATE:", alice is DEFAULT_PLAYER_STATE)

    alice["inventory"].append("sword")
    alice["health"] -= 15

    print()
    print("After Alice picks up a sword and takes 15 damage:")
    print("alice:", alice)
    print("bob:  ", bob)
    print("DEFAULT_PLAYER_STATE:", DEFAULT_PLAYER_STATE)

Run it (python step1_aliasing.py). This is the real, captured output:

id(alice) == 2651192817856
id(bob)   == 2651192817856
alice is bob: True
alice is DEFAULT_PLAYER_STATE: True

After Alice picks up a sword and takes 15 damage:
alice: {'health': 85, 'inventory': ['sword'], 'buffs': {'speed': 0, 'strength': 0}}
bob:   {'health': 85, 'inventory': ['sword'], 'buffs': {'speed': 0, 'strength': 0}}
DEFAULT_PLAYER_STATE: {'health': 85, 'inventory': ['sword'], 'buffs': {'speed': 0, 'strength': 0}}

id() returns a number that uniquely identifies an object for as long as it lives. Both alice and bob report the exact same id, which is Python confirming, in the most literal way possible, that they are the same object. Alice’s sword and 15 points of damage land on Bob too, and on the shared template itself, permanently corrupting the “default” state for every future player. This is not a contrived scenario. Returning a module-level constant directly from a factory function is an extremely common way to write this bug by accident.

Step 2: The almost-fix: why a shallow copy is not enough

The obvious fix is to stop handing out the same object and instead return a new one built from the template. Python dictionaries and lists both have a .copy() method for exactly this. Try it:

DEFAULT_PLAYER_STATE = {
    "health": 100,
    "inventory": [],
    "buffs": {"speed": 0, "strength": 0},
}


def new_player_v2():
    """BETTER, STILL BUGGY: makes a new top-level dict, but inventory/buffs
    inside it are the same list/dict objects as the template's."""
    return DEFAULT_PLAYER_STATE.copy()


if __name__ == "__main__":
    alice = new_player_v2()
    bob = new_player_v2()

    print("Top-level dicts are different objects now:")
    print("alice is bob:", alice is bob)
    print("alice is DEFAULT_PLAYER_STATE:", alice is DEFAULT_PLAYER_STATE)

    print()
    print("But the nested containers are still shared:")
    print("alice['inventory'] is bob['inventory']:", alice["inventory"] is bob["inventory"])
    print("alice['buffs'] is DEFAULT_PLAYER_STATE['buffs']:",
          alice["buffs"] is DEFAULT_PLAYER_STATE["buffs"])

    alice["health"] = 999                 # top-level key: safe now, only touches alice
    alice["inventory"].append("sword")    # mutates the SHARED list in place
    alice["buffs"]["speed"] += 5          # mutates the SHARED dict in place

    print()
    print("After Alice sets her own health, picks up a sword, and buffs speed:")
    print("alice:", alice)
    print("bob:  ", bob)
    print("DEFAULT_PLAYER_STATE:", DEFAULT_PLAYER_STATE)

Real captured output:

Top-level dicts are different objects now:
alice is bob: False
alice is DEFAULT_PLAYER_STATE: False

But the nested containers are still shared:
alice['inventory'] is bob['inventory']: True
alice['buffs'] is DEFAULT_PLAYER_STATE['buffs']: True

After Alice sets her own health, picks up a sword, and buffs speed:
alice: {'health': 999, 'inventory': ['sword'], 'buffs': {'speed': 5, 'strength': 0}}
bob:   {'health': 100, 'inventory': ['sword'], 'buffs': {'speed': 5, 'strength': 0}}
DEFAULT_PLAYER_STATE: {'health': 100, 'inventory': ['sword'], 'buffs': {'speed': 5, 'strength': 0}}

Progress and a new bug at the same time. Setting alice["health"] is now safe, because that key holds a plain integer stored directly in alice’s own dict. But alice["inventory"] and alice["buffs"] still point at the exact same list and dict objects as bob and the original template, because .copy() only builds one new layer. It copies the outer dict’s slots, but each slot still holds a reference to whatever object was already there. This is precisely what the documentation meant by a shallow copy inserting “references… to the objects found in the original.”

Step 3: The real fix: copy.deepcopy()

The standard library’s copy module has a function built for exactly this: copy.deepcopy() walks the entire structure recursively and builds a brand new object at every level, not just the top one.

import copy

DEFAULT_PLAYER_STATE = {
    "health": 100,
    "inventory": [],
    "buffs": {"speed": 0, "strength": 0},
}


def new_player_v3():
    """FIXED: recursively copies every nested list/dict too."""
    return copy.deepcopy(DEFAULT_PLAYER_STATE)


if __name__ == "__main__":
    alice = new_player_v3()
    bob = new_player_v3()

    print("Nested containers are now independent objects:")
    print("alice['inventory'] is bob['inventory']:", alice["inventory"] is bob["inventory"])
    print("alice['buffs'] is DEFAULT_PLAYER_STATE['buffs']:",
          alice["buffs"] is DEFAULT_PLAYER_STATE["buffs"])
    print("but they are still equal in content:")
    print("alice == DEFAULT_PLAYER_STATE:", alice == DEFAULT_PLAYER_STATE)

    alice["inventory"].append("sword")
    alice["buffs"]["speed"] += 5

    print()
    print("After Alice picks up a sword and buffs speed:")
    print("alice:", alice)
    print("bob:  ", bob)
    print("DEFAULT_PLAYER_STATE:", DEFAULT_PLAYER_STATE)

    # copy.copy() is the generic *shallow* copy hook, same problem as .copy()
    shallow_via_copy_module = copy.copy(DEFAULT_PLAYER_STATE)
    print()
    print("copy.copy() is still shallow:")
    print("shallow_via_copy_module['buffs'] is DEFAULT_PLAYER_STATE['buffs']:",
          shallow_via_copy_module["buffs"] is DEFAULT_PLAYER_STATE["buffs"])

Real captured output:

Nested containers are now independent objects:
alice['inventory'] is bob['inventory']: False
alice['buffs'] is DEFAULT_PLAYER_STATE['buffs']: False
but they are still equal in content:
alice == DEFAULT_PLAYER_STATE: True

After Alice picks up a sword and buffs speed:
alice: {'health': 100, 'inventory': ['sword'], 'buffs': {'speed': 5, 'strength': 0}}
bob:   {'health': 100, 'inventory': [], 'buffs': {'speed': 0, 'strength': 0}}
DEFAULT_PLAYER_STATE: {'health': 100, 'inventory': [], 'buffs': {'speed': 0, 'strength': 0}}

copy.copy() is still shallow:
shallow_via_copy_module['buffs'] is DEFAULT_PLAYER_STATE['buffs']: True

Alice’s sword and speed buff now belong to Alice alone. Bob and the template are untouched. Notice the last block too: copy.copy() is the generic, type-agnostic entry point for shallow copying (it is what dict.copy() and list.copy() effectively do under the hood for their own types), so it has exactly the same nested-sharing limitation as Step 2. There is no “copy a little more thoroughly than .copy() but not all the way” option in the standard library. It is either shallow or deep.

Step 4: How much does deep copying actually cost?

copy.deepcopy() is not free. It has to walk the entire object graph and decide, for every single value it encounters, how to copy that specific type. That is far more work than a shallow copy, which just iterates once over the top-level slots. Measure it directly against a hand-written function that knows the exact shape of the structure in advance:

import copy
import json
import time

DEFAULT_PLAYER_STATE = {
    "health": 100,
    "inventory": [],
    "buffs": {"speed": 0, "strength": 0},
    "spawn_point": (0, 0),  # a tuple, on purpose, see below
}


def clone_via_deepcopy():
    return copy.deepcopy(DEFAULT_PLAYER_STATE)


def clone_via_manual_rebuild():
    """Hand-written copy that knows the exact shape of the structure.
    Faster than the generic deepcopy because it skips deepcopy's
    type-dispatch machinery, but it will silently go stale the moment
    someone adds a new nested field and forgets to update this function."""
    return {
        "health": DEFAULT_PLAYER_STATE["health"],
        "inventory": list(DEFAULT_PLAYER_STATE["inventory"]),
        "buffs": dict(DEFAULT_PLAYER_STATE["buffs"]),
        "spawn_point": DEFAULT_PLAYER_STATE["spawn_point"],  # tuples are immutable, safe to share
    }


def clone_via_json_roundtrip():
    return json.loads(json.dumps(DEFAULT_PLAYER_STATE))


def time_it(fn, n=200_000):
    start = time.perf_counter()
    for _ in range(n):
        fn()
    return time.perf_counter() - start


if __name__ == "__main__":
    n = 200_000
    t_deep = time_it(clone_via_deepcopy, n)
    t_manual = time_it(clone_via_manual_rebuild, n)
    t_json = time_it(clone_via_json_roundtrip, n)

    print(f"copy.deepcopy():        {t_deep:.3f}s for {n:,} clones  ({t_deep / n * 1e6:.2f} us/call)")
    print(f"manual rebuild:         {t_manual:.3f}s for {n:,} clones  ({t_manual / n * 1e6:.2f} us/call)")
    print(f"json round-trip:        {t_json:.3f}s for {n:,} clones  ({t_json / n * 1e6:.2f} us/call)")
    print(f"deepcopy / manual ratio: {t_deep / t_manual:.2f}x slower")

    print()
    print("Now the JSON trick's real bug: it doesn't preserve tuples.")
    original_type = type(DEFAULT_PLAYER_STATE["spawn_point"])
    json_clone = clone_via_json_roundtrip()
    json_clone_type = type(json_clone["spawn_point"])
    deep_clone = clone_via_deepcopy()
    deep_clone_type = type(deep_clone["spawn_point"])

    print("original spawn_point type:  ", original_type)
    print("deepcopy clone type:        ", deep_clone_type)
    print("json round-trip clone type: ", json_clone_type)
    print("json_clone['spawn_point'] == original spawn_point:",
          json_clone["spawn_point"] == DEFAULT_PLAYER_STATE["spawn_point"])
    print("deep_clone['spawn_point'] == original spawn_point:",
          deep_clone["spawn_point"] == DEFAULT_PLAYER_STATE["spawn_point"])

Real captured output from this run (timing numbers will vary a little on your machine and between runs; multiple separate runs during the writing of this tutorial landed between 17x and 19x):

copy.deepcopy():        0.522s for 200,000 clones  (2.61 us/call)
manual rebuild:         0.030s for 200,000 clones  (0.15 us/call)
json round-trip:        0.439s for 200,000 clones  (2.19 us/call)
deepcopy / manual ratio: 17.45x slower

Now the JSON trick's real bug: it doesn't preserve tuples.
original spawn_point type:   <class 'tuple'>
deepcopy clone type:         <class 'tuple'>
json round-trip clone type:  <class 'list'>
json_clone['spawn_point'] == original spawn_point: False
deep_clone['spawn_point'] == original spawn_point: True

Two lessons here. First, on this small structure the generic copy.deepcopy() is roughly 17 times slower than a hand-written function that already knows the shape of the data, because it skips deepcopy’s internal type dispatch (treat the exact multiplier as an order-of-magnitude signal rather than a precise constant, since it moved between 17x and 19x across separate runs while writing this tutorial). That trade-off is real: a hand-rolled clone is faster but will silently drift out of sync the moment someone adds a new nested field and forgets to update it, while copy.deepcopy() automatically handles whatever shape the object actually has. Second, the popular “just round-trip it through JSON” shortcut is not actually a deep copy at all, it is a serialization format that happens to often look similar. It does not know what a Python tuple is, so spawn_point silently becomes a list, and Python does not consider a list and a tuple equal even when they hold the same elements, so json_clone["spawn_point"] == DEFAULT_PLAYER_STATE["spawn_point"] is flatly False. The properly deep-copied version keeps both its type and its equality with the original. Code elsewhere that does isinstance(spawn_point, tuple), relies on tuples being hashable, or just compares a “restored” value against what was saved will break in production, and it will break without the JSON round-trip ever raising an exception to warn you.

Step 5: The cycle problem: why deepcopy needs a memo dictionary

So far every structure has been a simple tree: a dict containing lists and more dicts, nothing pointing back at itself. Real object graphs are not always trees. Build a small text-adventure map where two rooms have exits back to each other, a cycle, and see what happens if you try to copy it with a naive recursive function that has no memory of what it has already started copying.

import copy
import sys


class Room:
    def __init__(self, name):
        self.name = name
        self.exits = {}  # maps a direction string to a Room object

    def __repr__(self):
        return f"Room({self.name!r})"


def build_world():
    cave = Room("Cave Entrance")
    tunnel = Room("Dark Tunnel")
    treasure = Room("Treasure Room")

    cave.exits["north"] = tunnel
    tunnel.exits["south"] = cave       # note: cave and tunnel now point back at each other
    tunnel.exits["east"] = treasure
    treasure.exits["west"] = tunnel
    return cave, tunnel, treasure


def naive_deepcopy(room):
    """BUGGY: no memory of rooms already being copied, so a cycle recurses forever."""
    new_room = Room(room.name)
    for direction, target in room.exits.items():
        new_room.exits[direction] = naive_deepcopy(target)
    return new_room


if __name__ == "__main__":
    cave, tunnel, treasure = build_world()

    print("Recursion limit on this interpreter:", sys.getrecursionlimit())
    print("Trying naive_deepcopy(cave) on a graph with a cycle...")
    try:
        naive_deepcopy(cave)
    except RecursionError as exc:
        print(f"RecursionError raised, as expected: {exc}")

    print()
    print("Now copy.deepcopy(cave), which tracks already-copied objects internally:")
    cave_copy = copy.deepcopy(cave)
    print("cave_copy is cave:", cave_copy is cave)
    print("cave_copy.exits['north'] is tunnel:", cave_copy.exits["north"] is tunnel)

    tunnel_copy = cave_copy.exits["north"]
    print("tunnel_copy.exits['south'] is cave_copy:", tunnel_copy.exits["south"] is cave_copy)
    print("(that's the whole point: the cycle in the copy points back to the")
    print(" SAME copied cave object, not a brand-new infinite chain of copies)")

    treasure_copy = tunnel_copy.exits["east"]
    print("treasure_copy.exits['west'] is tunnel_copy:", treasure_copy.exits["west"] is tunnel_copy)

Real captured output:

Recursion limit on this interpreter: 1000
Trying naive_deepcopy(cave) on a graph with a cycle...
RecursionError raised, as expected: maximum recursion depth exceeded

Now copy.deepcopy(cave), which tracks already-copied objects internally:
cave_copy is cave: False
cave_copy.exits['north'] is tunnel: False
tunnel_copy.exits['south'] is cave_copy: True
(that's the whole point: the cycle in the copy points back to the
 SAME copied cave object, not a brand-new infinite chain of copies)
treasure_copy.exits['west'] is tunnel_copy: True

The naive recursive copy tries to copy the cave, which needs a copy of the tunnel, which needs a copy of the cave, forever, until Python’s stack protection kicks in and raises a real RecursionError. This is exactly the failure mode the Python documentation warns about: “Recursive objects (compound objects that, directly or indirectly, contain a reference to themselves) may cause a recursive loop.” copy.deepcopy() avoids it by “keeping a memo dictionary of objects already copied during the current copying pass.” Every time it is about to copy an object, it first checks whether that object’s id is already in the memo. If it is, it reuses the copy it already made instead of copying it again. That is why tunnel_copy.exits["south"] is not just a copy of the cave, it is the exact same cave_copy object you already have a reference to. Without that memo, deepcopy would not just crash on cycles, it would also incorrectly duplicate any object that is legitimately reachable from two different paths, turning one shared object into two independent, silently diverging ones.

Step 6: When deepcopy cannot help you: writing a custom __deepcopy__

Some objects genuinely cannot be copied by any generic algorithm, because copying them does not make conceptual sense. A threading.Lock is a real operating-system synchronization primitive, not a plain data value, and Python’s copy machinery has no idea how to duplicate one. Prove it:

import copy
import threading


class ConnectionPoolBuggy:
    """Holds a real OS-level lock. deepcopy has no generic way to copy that."""

    def __init__(self, name, max_size=5):
        self.name = name
        self.max_size = max_size
        self.lock = threading.Lock()
        self.active = 0

    def __repr__(self):
        return f"ConnectionPoolBuggy(name={self.name!r}, active={self.active})"


if __name__ == "__main__":
    buggy = ConnectionPoolBuggy("primary")
    print("Trying copy.deepcopy() on an object holding a threading.Lock:")
    try:
        copy.deepcopy(buggy)
    except TypeError as exc:
        print(f"TypeError raised, as expected: {exc}")

Real captured output:

Trying copy.deepcopy() on an object holding a threading.Lock:
TypeError raised, as expected: cannot pickle '_thread.lock' object

That error message mentions pickling because, as the documentation explains, “the copy module uses the registered pickle functions from the copyreg module” as its default mechanism for objects it does not have a built-in rule for. Python’s documentation also lists a small set of types the module intentionally never copies at all: “module, method, stack trace, stack frame, file, socket, window, or any similar types.” A thread lock is not named explicitly in that list, but it is exactly the kind of “similar type” the warning is about: an OS-level resource, not a plain value, and there is no sensible generic definition of what “a copy of this lock” would even mean.

The fix is to tell Python exactly how to build a copy of your class, by defining a __deepcopy__ method. According to the documentation, this method “is called to implement the deep copy operation; it is passed one argument, the memo dictionary.” The right move here is not to attempt to copy the old lock at all. It is to give the new object a brand-new lock of its own, since a lock only means something in the context of the specific object it protects.

class ConnectionPoolFixed:
    """Same idea, but tells deepcopy exactly how to build a copy:
    give the copy its own brand-new lock instead of trying to clone the old one."""

    def __init__(self, name, max_size=5):
        self.name = name
        self.max_size = max_size
        self.lock = threading.Lock()
        self.active = 0

    def __deepcopy__(self, memo):
        clone = ConnectionPoolFixed(self.name, self.max_size)
        clone.active = self.active
        # clone.lock is already a fresh threading.Lock() from __init__, so
        # we deliberately do NOT try to copy self.lock.
        memo[id(self)] = clone
        return clone

    def __repr__(self):
        return f"ConnectionPoolFixed(name={self.name!r}, active={self.active})"


if __name__ == "__main__":
    fixed = ConnectionPoolFixed("primary", max_size=10)
    fixed.active = 3
    fixed_copy = copy.deepcopy(fixed)

    print("fixed_copy:", fixed_copy)
    print("fixed_copy is fixed:", fixed_copy is fixed)
    print("fixed_copy.lock is fixed.lock:", fixed_copy.lock is fixed.lock)
    print("fixed_copy.active == fixed.active:", fixed_copy.active == fixed.active)

    # Prove the two locks are genuinely independent: acquiring one does not
    # block acquiring the other.
    fixed.lock.acquire()
    acquired_on_copy = fixed_copy.lock.acquire(timeout=1)
    print("Could acquire fixed_copy.lock while fixed.lock is held:", acquired_on_copy)
    fixed.lock.release()
    fixed_copy.lock.release()

Real captured output:

fixed_copy: ConnectionPoolFixed(name='primary', active=3)
fixed_copy is fixed: False
fixed_copy.lock is fixed.lock: False
fixed_copy.active == fixed.active: True
Could acquire fixed_copy.lock while fixed.lock is held: True

The two locks are provably independent: holding fixed.lock does not block acquiring fixed_copy.lock, which would not be true if they were secretly the same object. Setting memo[id(self)] = clone before returning is standard practice for a __deepcopy__ implementation, even in a simple case like this one where the class does not reference itself, because it is what makes the same memo-based cycle protection from Step 5 work correctly if this object ever does end up being part of a larger, more tangled graph.

Step 7: The cousin bug: mutable default arguments

There is a second, very common Python bug with exactly the same root cause: a single mutable object that everyone assumed was private ends up shared. It just wears a different disguise. A function default argument is evaluated exactly once, at the moment the function is defined, not on every call.

def add_item_buggy(item, cart=[]):
    """BUGGY: the default list is created ONCE, when the function is
    defined, and every call that doesn't pass its own cart reuses it."""
    cart.append(item)
    return cart


def add_item_fixed(item, cart=None):
    """FIXED: use None as the sentinel, create a fresh list per call."""
    if cart is None:
        cart = []
    cart.append(item)
    return cart


if __name__ == "__main__":
    print("Buggy version: two 'separate' shopping carts:")
    alice_cart = add_item_buggy("book")
    bob_cart = add_item_buggy("headphones")
    print("alice_cart:", alice_cart)
    print("bob_cart:  ", bob_cart)
    print("alice_cart is bob_cart:", alice_cart is bob_cart)

    print()
    print("Fixed version:")
    alice_cart2 = add_item_fixed("book")
    bob_cart2 = add_item_fixed("headphones")
    print("alice_cart2:", alice_cart2)
    print("bob_cart2:  ", bob_cart2)
    print("alice_cart2 is bob_cart2:", alice_cart2 is bob_cart2)

    print()
    print("Proof the default is built once, at definition time:")
    print("add_item_buggy.__defaults__:", add_item_buggy.__defaults__)
    print("(that list is the SAME object every un-cart-passing call mutates)")

Real captured output:

Buggy version: two 'separate' shopping carts:
alice_cart: ['book', 'headphones']
bob_cart:   ['book', 'headphones']
alice_cart is bob_cart: True

Fixed version:
alice_cart2: ['book']
bob_cart2:   ['headphones']
alice_cart2 is bob_cart2: False

Proof the default is built once, at definition time:
add_item_buggy.__defaults__: (['book', 'headphones'],)
(that list is the SAME object every un-cart-passing call mutates)

Calling add_item_buggy.__defaults__ directly shows the smoking gun: it is a tuple holding the actual list object that gets reused, and by the time you inspect it, it already contains both “book” and “headphones” even though each call only ever appended one item. The safe pattern is the one shown in add_item_fixed(): use an immutable sentinel like None as the default, and build a fresh mutable object inside the function body on every call. This is worth calling out here specifically because it is the same underlying lesson as Steps 1 and 2: nothing in Python automatically copies a mutable object for you just because it looks like it “should” be a fresh, private value.

Step 8: Put it all together and verify it with pytest

Consolidate every fix from this tutorial into one small module, then write tests that would fail loudly if any of the bugs above crept back in.

"""player_state.py"""
import copy
import threading

DEFAULT_PLAYER_STATE = {
    "health": 100,
    "inventory": [],
    "buffs": {"speed": 0, "strength": 0},
    "spawn_point": (0, 0),
}


def new_player():
    """Every new player gets a fully independent copy of the template."""
    return copy.deepcopy(DEFAULT_PLAYER_STATE)


class Room:
    def __init__(self, name):
        self.name = name
        self.exits = {}

    def __repr__(self):
        return f"Room({self.name!r})"


class ConnectionPool:
    def __init__(self, name, max_size=5):
        self.name = name
        self.max_size = max_size
        self.lock = threading.Lock()
        self.active = 0

    def __deepcopy__(self, memo):
        clone = ConnectionPool(self.name, self.max_size)
        clone.active = self.active
        memo[id(self)] = clone
        return clone

    def __repr__(self):
        return f"ConnectionPool(name={self.name!r}, active={self.active})"


def add_item(item, cart=None):
    if cart is None:
        cart = []
    cart.append(item)
    return cart
"""test_player_state.py"""
import copy

import pytest

from player_state import (
    DEFAULT_PLAYER_STATE,
    ConnectionPool,
    Room,
    add_item,
    new_player,
)


def test_new_player_is_independent_of_template():
    alice = new_player()
    alice["inventory"].append("sword")
    alice["buffs"]["speed"] += 5
    assert DEFAULT_PLAYER_STATE["inventory"] == []
    assert DEFAULT_PLAYER_STATE["buffs"]["speed"] == 0


def test_new_player_is_independent_of_other_players():
    alice = new_player()
    bob = new_player()
    alice["inventory"].append("shield")
    assert bob["inventory"] == []
    assert alice["inventory"] is not bob["inventory"]


def test_new_player_starts_equal_to_template():
    alice = new_player()
    assert alice == DEFAULT_PLAYER_STATE
    assert alice is not DEFAULT_PLAYER_STATE


def test_room_graph_with_cycle_deepcopies_without_error():
    cave = Room("Cave Entrance")
    tunnel = Room("Dark Tunnel")
    cave.exits["north"] = tunnel
    tunnel.exits["south"] = cave  # cycle

    cave_copy = copy.deepcopy(cave)

    assert cave_copy is not cave
    tunnel_copy = cave_copy.exits["north"]
    assert tunnel_copy is not tunnel
    # the cycle must point back to the SAME copied object
    assert tunnel_copy.exits["south"] is cave_copy


def test_connection_pool_deepcopy_gets_its_own_lock():
    pool = ConnectionPool("primary")
    pool.active = 4
    pool_copy = copy.deepcopy(pool)

    assert pool_copy is not pool
    assert pool_copy.lock is not pool.lock
    assert pool_copy.active == pool.active

    pool.lock.acquire()
    try:
        assert pool_copy.lock.acquire(timeout=1) is True
        pool_copy.lock.release()
    finally:
        pool.lock.release()


def test_add_item_does_not_leak_across_calls():
    first = add_item("book")
    second = add_item("headphones")
    assert first == ["book"]
    assert second == ["headphones"]
    assert first is not second


def test_add_item_still_supports_an_explicit_cart():
    cart = []
    add_item("book", cart)
    add_item("pen", cart)
    assert cart == ["book", "pen"]

Run pytest test_player_state.py -v. Real captured output:

============================= test session starts =============================
platform win32 -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0
collecting ... collected 7 items

test_player_state.py::test_new_player_is_independent_of_template PASSED  [ 14%]
test_player_state.py::test_new_player_is_independent_of_other_players PASSED [ 28%]
test_player_state.py::test_new_player_starts_equal_to_template PASSED    [ 42%]
test_player_state.py::test_room_graph_with_cycle_deepcopies_without_error PASSED [ 57%]
test_player_state.py::test_connection_pool_deepcopy_gets_its_own_lock PASSED [ 71%]
test_player_state.py::test_add_item_does_not_leak_across_calls PASSED    [ 85%]
test_player_state.py::test_add_item_still_supports_an_explicit_cart PASSED [100%]

============================== 7 passed in 0.02s ==============================

Each test targets exactly one of the bugs reproduced earlier in this tutorial. If someone later “simplifies” new_player() back to a shallow .copy(), or removes the __deepcopy__ override from ConnectionPool, or reintroduces a mutable default argument in add_item(), the corresponding test fails immediately instead of the bug surfacing weeks later as a confusing production incident.

Common mistakes and gotchas

  • Assuming .copy() is “safe enough” for nested data. It only protects the outer layer. Any list, dict, or object nested inside is still shared, as shown in Step 2.
  • Assuming the JSON round-trip trick is a real deep copy. It is a serialization format, not a copy operation, and it silently changes types it does not understand, such as turning tuples into lists, as shown in Step 4.
  • Forgetting that immutable objects never need this treatment. Integers, strings, and tuples cannot be mutated in place, so sharing a reference to one is always safe. Only worry about copying for lists, dicts, sets, and mutable class instances.
  • Calling copy.deepcopy() on an object that wraps an OS resource. Locks, open file handles, and sockets are not values that a generic algorithm can duplicate. Either exclude them with a custom __deepcopy__, as shown in Step 6, or reconsider whether the object holding them should be deep-copied at all.
  • Writing a mutable default argument. def f(x, cache=[]) creates one list, shared by every call that does not explicitly pass its own. Use None as the sentinel instead, as shown in Step 7.

How to confirm everything works end to end

Put the two files (player_state.py and test_player_state.py) in an empty directory, install pytest with pip install pytest, and run pytest test_player_state.py -v. All seven tests should pass with no warnings. As a manual sanity check on top of the automated tests, open a Python shell in that directory and run:

import copy
from player_state import new_player, DEFAULT_PLAYER_STATE

a = new_player()
b = new_player()
a["inventory"].append("torch")
print(a["inventory"], b["inventory"], DEFAULT_PLAYER_STATE["inventory"])
# should print: ['torch'] [] []

If you see ['torch'] [] [], the isolation is working correctly: your change to one player’s inventory is not visible anywhere else.

Next steps

The underlying lesson in this tutorial, that a single shared mutable object can silently couple two things that are supposed to be independent, shows up in a lot of other places once you know to look for it. If you want to keep building on it, two related tutorials on this site are worth reading next: How to Checkpoint and Resume a Python Training Job to Survive Spot Instance Interruption deals with the closely related problem of serializing an object’s state safely (the copy module and Python’s pickle module actually share the same underlying protocol for objects that do not define their own copy behavior), and How to Build a Bulkhead in Python to Stop a Slow Dependency From Starving the Rest covers a different flavor of the same core idea: giving each independent piece of a system its own dedicated resource instead of letting them silently share one.

Tags:

DebuggingObject-Oriented ProgrammingpytestPythonSoftware Engineering

Share

The New York State Capitol building in Albany, New York, photographed from the Empire State Plaza under a clear blue sky
Previous Post

MIT Technology Review’s Liability Analysis Turns Rogue AI Agents Into a Legal Blind Spot

An ornate brass hotel concierge call bell on a wooden desk
Next Post

Instinct’s AI Agent Quadruples to a $10 Billion Valuation Weeks After a Privacy Backlash

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
30 Sep
How to Detect and Strip Invisible Unicode in Python to Stop ASCII Smuggling and Trojan Source
30 Sep
Two Cloudflare Agent Billing Betas Turn Web Monetization Into a Question of Who Holds the Meter
Trending
September 30, 2026
How to Detect and Strip Invisible Unicode in Python to Stop ASCII Smuggling and Trojan Source
September 30, 2026
Two Cloudflare Agent Billing Betas Turn Web Monetization Into a Question of Who Holds the Meter
September 30, 2026
Attackers Exploit a Hex-Encoding Bypass in Cisco SD-WAN Manager, and CISA Sets an October 3 Deadline
September 30, 2026
How to Enforce Guardrails on AI-Generated Terraform With Open Policy Agent and Rego
September 30, 2026
AMD Agrees to Buy Fei-Fei Li’s World Labs for $8.2 Billion to Steer Its Chip Roadmap
September 29, 2026
How to Build a Merkle Tree Certificate Issuer in Python to Keep Post-Quantum Certificates Small

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026