S
Sohel Alam
Guest
A few weeks ago I was testing a small feature nothing fancy, just duplicating a dictionary before running an update, the way you're taught to "be safe." I only touched the copy. And somehow, the original changed too. I hadn't touched it. I hadn't reassigned it. It just changed.
It wasn't a Python bug. It was something I'd been getting wrong without realizing it: a variable doesn't hold an object it holds a reference to one. Once that clicked, this entire category of "silent" bugs stopped feeling random.
You'd expect this to print:
You made a copy. You only modified the copy. The original should be untouched, right?
The original dictionary changed even though you never touched original directly. You only touched copy_dict.
Before looking at .copy(), consider this:
Output:
Nothing was copied here. backup and original are two names pointing at the same dictionary object. Change it through one name, and you see the change through the other.
This is called aliasing, and it's worth keeping in your back pocket because it's the same underlying idea .copy() runs into next, just without the false sense of safety.
dict.copy() creates a new dictionary object, but it does not create new objects for the values inside it. It just copies the references to those values.
So when you do:
Python creates a new dictionary, but the value at "skills" in both original and copy_dict is still pointing to the same list object.
You can prove this:
is checks object identity not value equality and it returns True. Both keys are pointing to the exact same list object. Two boxes, one shared item inside.
.append() doesn't create a new list it mutates the existing one in place. Since both dictionaries are pointing at that same list, both "see" the change. This is the same trap you'd run into with shared lists just wearing a dictionary's clothes.
.append() mutates the existing list the object stays the same, its contents change. Compare that to reassigning copy_dict["skills"] to a brand-new list instead:
Output:
This time original is untouched because copy_dict["skills"] now points to a different list entirely. It never modified the old one. Mutation changes an object that other references can still see.
Reassignment points a key to a new object, leaving the old one and anyone else pointing at it alone.
Whenever a dictionary seems to "change on its own," it's worth checking which of the two happened: was a shared object mutated in place, or was a key simply reassigned to something new? Only the first one leaks across shared references.
Output:
Same problem. user.copy() copied the outer dictionary, but "profile" still points to the same nested dictionary object. You can confirm both halves of that:
The outer dictionaries are different objects. The nested profile dictionary is not there was only ever one. Change it through backup, and user sees the change too.
A lot of developers "fix" this by writing their own manual copy:
This looks like a fresh copy. It isn't. It's exactly the same shallow copy .copy() already gives you just written by hand. The values are still shared references. This "fix" changes nothing about the actual bug; it just adds a false sense of security.
If your dictionary contains mutable objects (lists, other dicts, sets) and you want a truly independent copy, you need a deep copy one that recursively copies every nested object, not just the top level.
Now original and deep_copy no longer share the skills list modifying one doesn't touch the other.
One nuance worth knowing: deepcopy() keeps track of objects it has already copied. So if two keys in your original dictionary pointed to the same nested object, the copy preserves that relationship between the copies too it isn't shared with the original anymore, but it's still shared within the copy. It's not simply "everything becomes independent of everything"; it's "the copy no longer shares state with the original."
Once you understand the problem, it's tempting to reach for deepcopy() by default, everywhere, "just to be safe."
That's usually overkill. Deep copying walks the entire nested structure and rebuilds it which costs more memory and more time than a shallow copy, especially on large or deeply nested data. If your dictionary doesn't contain anything mutable, or you're only replacing top-level values, .copy() already does the job:
Output:
There's no shared mutable object being changed here, so a shallow copy is enough. Reach for deepcopy() when you actually need it not as a default habit.
If you've read my earlier piece on functions secretly changing data you never passed to them, this next part will look familiar because it's the exact same root cause, just showing up through a function signature instead of .copy():
Expected output:
Actual output:
The default argument cart={} is created once, when the function is defined — not each time it's called. Every call without an explicit cart argument reuses that same dictionary object. It's not a copying bug this time, but the root cause is identical to everything above: unintended sharing of a mutable object across places that expected to be independent — the same trap, wearing a function's clothes this time instead of a dictionary's.
Fix:
Now every call that doesn't pass cart gets a fresh dictionary.
Not every case needs deepcopy(). Whether .copy() is enough depends entirely on what's inside the dictionary:
Honestly, the easiest way to decide is to just ask yourself what kind of independence you need.
If your dictionary only holds immutable values, or you're genuinely fine with nested objects staying shared, or you only ever plan to reassign keys rather than mutate what's inside them a plain .copy() is enough, and reaching for anything heavier is wasted effort.
If it holds nested lists, dicts, or sets, and you need the copy to be completely unaffected by changes to the original (or vice versa), that's when copy.deepcopy() earns its keep.
And sometimes you don't want a copy at all you want two names pointing at the same dictionary, because that's exactly the behavior you're relying on somewhere else in your code. That's not a bug either. That's just aliasing, used on purpose.
A Python dictionary's .copy() only copies one level deep. Anything mutable sitting inside it a list, another dict, a set isn't duplicated. It's shared. Two dictionaries, one object living inside both.
The surprising part isn't that .copy() failed it didn't. It performed a shallow copy exactly as designed. The surprise only happens when you expect "copy" to mean "fully independent" without checking what's nested inside. And that doesn't automatically mean the fix is deepcopy() sometimes the fix is just noticing you were mutating something you meant to reassign, or that the sharing was fine all along.
The whole picture comes down to one chain:
Aliasing (no copy same object under two names) → shallow copy (new outer dictionary, same nested objects) → mutation vs. reassignment (which one you do determines what leaks) → deep copy (independent all the way down, when you actually need it).
Once I started placing a piece of code correctly on that chain, "the dictionary changed and I never touched it" stopped being a mystery it became just another Tuesday debugging session.
It wasn't a Python bug. It was something I'd been getting wrong without realizing it: a variable doesn't hold an object it holds a reference to one. Once that clicked, this entire category of "silent" bugs stopped feeling random.
What You Expect
Code:
original = {"name": "Sohel", "skills": ["Python", "JS"]}
copy_dict = original.copy()
copy_dict["skills"].append("React")
print(original["skills"])
You'd expect this to print:
Code:
['Python', 'JS']
You made a copy. You only modified the copy. The original should be untouched, right?
What Actually Happens
Code:
['Python', 'JS', 'React']
The original dictionary changed even though you never touched original directly. You only touched copy_dict.
First, There's Not Always a Copy at All
Before looking at .copy(), consider this:
Code:
original = {"name": "Sohel"}
backup = original
backup["name"] = "Alex"
print(original)
Output:
Code:
{'name': 'Alex'}
Nothing was copied here. backup and original are two names pointing at the same dictionary object. Change it through one name, and you see the change through the other.
Code:
print(original is backup)
# True
This is called aliasing, and it's worth keeping in your back pocket because it's the same underlying idea .copy() runs into next, just without the false sense of safety.
Why This Happens: Shallow Copy Doesn't Mean What You Think
dict.copy() creates a new dictionary object, but it does not create new objects for the values inside it. It just copies the references to those values.
So when you do:
Code:
copy_dict = original.copy()
Python creates a new dictionary, but the value at "skills" in both original and copy_dict is still pointing to the same list object.
You can prove this:
Code:
print(original["skills"] is copy_dict["skills"])
# True
is checks object identity not value equality and it returns True. Both keys are pointing to the exact same list object. Two boxes, one shared item inside.
Code:
original copy_dict
┌─────────────────┐ ┌─────────────────┐
│ name: "Sohel" │ │ name: "Sohel" │
│ skills: ●────────┼───┐ ┌───┼───────────● skills
└─────────────────┘ │ │ └─────────────────┘
▼ ▼
["Python", "JS", "React"]
(one shared list)
.append() doesn't create a new list it mutates the existing one in place. Since both dictionaries are pointing at that same list, both "see" the change. This is the same trap you'd run into with shared lists just wearing a dictionary's clothes.
Mutation and Reassignment Are Not the Same Thing
.append() mutates the existing list the object stays the same, its contents change. Compare that to reassigning copy_dict["skills"] to a brand-new list instead:
Code:
original = {"skills": ["Python", "JS"]}
copy_dict = original.copy()
copy_dict["skills"] = ["Python", "JS", "React"]
print(original["skills"])
print(copy_dict["skills"])
Output:
Code:
['Python', 'JS']
['Python', 'JS', 'React']
This time original is untouched because copy_dict["skills"] now points to a different list entirely. It never modified the old one. Mutation changes an object that other references can still see.
Reassignment points a key to a new object, leaving the old one and anyone else pointing at it alone.
Whenever a dictionary seems to "change on its own," it's worth checking which of the two happened: was a shared object mutated in place, or was a key simply reassigned to something new? Only the first one leaks across shared references.
It Gets Worse With Nested Dictionaries
Code:
user = {
"profile": {"theme": "dark", "language": "en"}
}
backup = user.copy()
backup["profile"]["theme"] = "light"
print(user["profile"]["theme"])
Output:
Code:
light
Same problem. user.copy() copied the outer dictionary, but "profile" still points to the same nested dictionary object. You can confirm both halves of that:
Code:
print(user is backup)
# False
print(user["profile"] is backup["profile"])
# True
The outer dictionaries are different objects. The nested profile dictionary is not there was only ever one. Change it through backup, and user sees the change too.
The Half-Fix Everyone Reaches For (And Why It's Not Enough)
A lot of developers "fix" this by writing their own manual copy:
Code:
copy_dict = {key: value for key, value in original.items()}
This looks like a fresh copy. It isn't. It's exactly the same shallow copy .copy() already gives you just written by hand. The values are still shared references. This "fix" changes nothing about the actual bug; it just adds a false sense of security.
When You Actually Need copy.deepcopy()
If your dictionary contains mutable objects (lists, other dicts, sets) and you want a truly independent copy, you need a deep copy one that recursively copies every nested object, not just the top level.
Code:
import copy
original = {"name": "Sohel", "skills": ["Python", "JS"]}
deep_copy = copy.deepcopy(original)
deep_copy["skills"].append("React")
print(original["skills"]) # ['Python', 'JS']
print(deep_copy["skills"]) # ['Python', 'JS', 'React']
Now original and deep_copy no longer share the skills list modifying one doesn't touch the other.
One nuance worth knowing: deepcopy() keeps track of objects it has already copied. So if two keys in your original dictionary pointed to the same nested object, the copy preserves that relationship between the copies too it isn't shared with the original anymore, but it's still shared within the copy. It's not simply "everything becomes independent of everything"; it's "the copy no longer shares state with the original."
Don't Deep-Copy Everything
Once you understand the problem, it's tempting to reach for deepcopy() by default, everywhere, "just to be safe."
That's usually overkill. Deep copying walks the entire nested structure and rebuilds it which costs more memory and more time than a shallow copy, especially on large or deeply nested data. If your dictionary doesn't contain anything mutable, or you're only replacing top-level values, .copy() already does the job:
Code:
config = {"version": 2, "debug": False}
backup = config.copy()
backup["version"] = 3
print(config)
Output:
Code:
{'version': 2, 'debug': False}
There's no shared mutable object being changed here, so a shallow copy is enough. Reach for deepcopy() when you actually need it not as a default habit.
The Other Silent Trap: Mutable Default Arguments
If you've read my earlier piece on functions secretly changing data you never passed to them, this next part will look familiar because it's the exact same root cause, just showing up through a function signature instead of .copy():
Code:
def add_item(item, cart={}):
cart[item] = cart.get(item, 0) + 1
return cart
print(add_item("laptop"))
print(add_item("mouse"))
Expected output:
Code:
{'laptop': 1}
{'mouse': 1}
Actual output:
Code:
{'laptop': 1}
{'laptop': 1, 'mouse': 1}
The default argument cart={} is created once, when the function is defined — not each time it's called. Every call without an explicit cart argument reuses that same dictionary object. It's not a copying bug this time, but the root cause is identical to everything above: unintended sharing of a mutable object across places that expected to be independent — the same trap, wearing a function's clothes this time instead of a dictionary's.
Fix:
Code:
def add_item(item, cart=None):
if cart is None:
cart = {}
cart[item] = cart.get(item, 0) + 1
return cart
Now every call that doesn't pass cart gets a fresh dictionary.
Quick Reference: What's Safe to Shallow-Copy
Not every case needs deepcopy(). Whether .copy() is enough depends entirely on what's inside the dictionary:
| Value type in dict | Is a shallow copy safe? |
|---|---|
| int, float, str, bool | Yes — these can't be mutated in place at all |
| tuple containing only immutable values | Yes — same reason |
| list, dict, set | Only if you don't need it independent from the original |
| tuple containing a mutable value (e.g. ([1, 2],)) | Only if you don't need that inner list independent — it's still shared |
Which Copy Should You Actually Use?
Honestly, the easiest way to decide is to just ask yourself what kind of independence you need.
If your dictionary only holds immutable values, or you're genuinely fine with nested objects staying shared, or you only ever plan to reassign keys rather than mutate what's inside them a plain .copy() is enough, and reaching for anything heavier is wasted effort.
If it holds nested lists, dicts, or sets, and you need the copy to be completely unaffected by changes to the original (or vice versa), that's when copy.deepcopy() earns its keep.
And sometimes you don't want a copy at all you want two names pointing at the same dictionary, because that's exactly the behavior you're relying on somewhere else in your code. That's not a bug either. That's just aliasing, used on purpose.
The Mental Model to Remember
A Python dictionary's .copy() only copies one level deep. Anything mutable sitting inside it a list, another dict, a set isn't duplicated. It's shared. Two dictionaries, one object living inside both.
The surprising part isn't that .copy() failed it didn't. It performed a shallow copy exactly as designed. The surprise only happens when you expect "copy" to mean "fully independent" without checking what's nested inside. And that doesn't automatically mean the fix is deepcopy() sometimes the fix is just noticing you were mutating something you meant to reassign, or that the sharing was fine all along.
The whole picture comes down to one chain:
Aliasing (no copy same object under two names) → shallow copy (new outer dictionary, same nested objects) → mutation vs. reassignment (which one you do determines what leaks) → deep copy (independent all the way down, when you actually need it).
Once I started placing a piece of code correctly on that chain, "the dictionary changed and I never touched it" stopped being a mystery it became just another Tuesday debugging session.