I still remember the first time a shallow copy bug cost me an afternoon. I was building a small inventory tool, I copied a list of lists thinking I had two independent inventories, and then I edited one and watched the other change too. That was my introduction to one of Python’s most misunderstood topics: how copying objects actually works under the hood. In this guide I want to walk you through everything I’ve learned about shallow copies — from the absolute basics to the memory management details that explain why they behave the way they do.
What Is a Shallow Copy, Really?
When I copy an object in Python, I’m not always getting an independent clone. A shallow copy creates a new object, but instead of recursively copying everything inside it, it just copies references to the nested objects. So the outer container is new, but the inner containers are shared.
Think of it like photocopying the cover of a folder full of documents, but not the documents themselves. The new folder is physically different, but if someone scribbles on a document inside the original folder, that same scribble shows up in your “copy” too, because both folders are pointing at the same documents.
This matters because Python variables are names bound to objects, not boxes holding values. When I write a = [1, 2, 3], a is a reference to a list object living somewhere in memory. Copying a at different depths changes what gets duplicated and what gets shared.
The Three Levels of “Copying” in Python
Before diving into code, I like to mentally separate three distinct operations:
- Assignment (
b = a) — no copy at all, just another name for the same object. - Shallow copy — a new outer object, but nested objects are shared.
- Deep copy — a new outer object and recursively new copies of everything nested inside.
Understanding this spectrum is the key to avoiding subtle bugs.
Assignment Is Not Copying
original = [1, 2, [3, 4]]
alias = original
alias.append(5)
print(original) # [1, 2, [3, 4], 5]
Here, alias and original are literally the same object. There’s only one list in memory, with two labels pointing at it. This trips up beginners constantly — I know it tripped me up.
Creating a True Shallow Copy
Python gives me several ways to make a shallow copy, depending on the data type.
Using the copy module
import copy
original = [1, 2, [3, 4]]
shallow = copy.copy(original)
shallow.append(99)
print(original) # [1, 2, [3, 4]]
print(shallow) # [1, 2, [3, 4], 99]
The top-level list is now independent — adding 99 to shallow didn’t affect original. But watch what happens with the nested list:
shallow[2].append(5)
print(original) # [1, 2, [3, 4, 5]]
print(shallow) # [1, 2, [3, 4, 5], 99]
Both lists show the change, because shallow[2] and original[2] are still the exact same nested list object. That’s the defining characteristic of a shallow copy.
Type-specific shortcuts
I don’t always need copy.copy(). Many built-in types offer their own shallow-copy mechanisms:
list_copy = original_list.copy()
list_copy2 = original_list[:]
list_copy3 = list(original_list)
dict_copy = original_dict.copy()
dict_copy2 = dict(original_dict)
set_copy = original_set.copy()
All of these behave identically to copy.copy() for their respective types — new outer container, shared inner references.
Why This Happens: Memory Management Internals
To really understand shallow copying, I find it helps to think about how CPython (the reference implementation most of us use) manages memory.
Every Python object lives on the heap and has a reference count. A variable name doesn’t store a value directly — it stores a pointer to an object’s location in memory. When I do b = a, Python increments the reference count of the object a points to, and b now points to the same address.
When copy.copy() runs on a container, it allocates a new block of memory for the container itself (a new list object, a new dict object, etc.), but for each element inside, it copies the reference, not the underlying object. So the reference count of each inner object goes up by one, but no new inner objects are created.
You can actually verify this yourself using id(), which returns the memory address (identity) of an object:
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
print(id(original) == id(shallow)) # False — different outer list
print(id(original[0]) == id(shallow[0])) # True — same inner list
This is the clearest proof I know of that shallow copy only duplicates one layer.
How __copy__ Works for Custom Objects
For custom classes, copy.copy() looks for a __copy__ method. If your class doesn’t define one, Python falls back to copying the instance’s __dict__ shallowly and constructing a new instance without calling __init__.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
import copy
p1 = Point(1, 2)
p2 = copy.copy(p1)
p2.x = 99
print(p1) # Point(1, 2)
print(p2) # Point(99, 2)
Since x and y here are immutable integers, this looks like a full copy — but if Point held a mutable attribute like a list, that list would still be shared between p1 and p2.
You can override this behavior explicitly:
class Container:
def __init__(self, items):
self.items = items
def __copy__(self):
new_obj = Container(self.items) # still shares self.items
return new_obj
Defining __copy__ gives you full control over exactly what “shallow” means for your object.
Performance and Complexity
One reason I reach for shallow copies instead of deep copies whenever possible is speed. A shallow copy only needs to iterate once over the top-level elements and copy references — this is O(n) with respect to the number of top-level items, and each reference copy is O(1).
A deep copy, by contrast, has to recursively traverse the entire object graph, which can be O(n) in the total number of objects at every nesting level, and it has to handle cycles (an object referencing itself) using a memo dictionary to avoid infinite recursion. That extra bookkeeping and recursive traversal make copy.deepcopy() noticeably slower for large or deeply nested structures.
If your data is flat (a list of numbers or strings, for example), shallow and deep copies behave identically in outcome, but shallow copy will always be faster because there’s nothing mutable nested inside to worry about.
Real-World Use Cases
I use shallow copies constantly in practical Python work:
- Snapshotting simple configuration dictionaries before modifying them, when I know the values are immutable (strings, numbers, booleans).
- Passing a “safe” version of a list to a function that might append or remove items, without letting it mutate my original list’s length.
- Caching function results where the returned container shouldn’t be mutated by the caller, but its contents are read-only anyway.
- Working with NumPy or pandas transitional code, where I copy a list of DataFrame references while intentionally keeping the underlying DataFrames shared to save memory.
Common Mistakes I’ve Made (and Seen Others Make)
Mistake 1: Assuming list.copy() is enough for nested data.
matrix = [[0, 0], [0, 0]]
matrix_copy = matrix.copy()
matrix_copy[0][0] = 1
print(matrix) # [[1, 0], [0, 0]] — oops
If you’re working with a matrix or any list of lists, you almost always want copy.deepcopy() instead.
Mistake 2: Using copy.copy() on objects with circular references and expecting it to just work like deepcopy. Shallow copy doesn’t traverse recursively at all, so circular references aren’t even a concern for it — but that also means you won’t get the independence you might expect.
Mistake 3: Forgetting that default mutable arguments in function definitions interact with this same reference-sharing behavior.
def add_item(item, target=[]):
target.append(item)
return target
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] — the default list persists across calls!
This isn’t technically about copy, but it stems from the same root cause: Python passes and stores references, not values.
Debugging Tips
When I suspect a shallow-copy-related bug, my go-to debugging steps are:
- Use
id()on the suspect objects to confirm whether they’re actually the same object in memory. - Use
isinstead of==to check identity rather than equality —a is btells me if they’re the same object;a == bjust tells me if they look equal. - Print nested structures before and after a mutation to see exactly which layer changed.
- When in doubt, reach for
copy.deepcopy()and only optimize back to a shallow copy once you’ve confirmed it’s safe.
Pythonic Best Practices
- Prefer
copy.copy()or type-specific.copy()methods over manual reconstruction — they’re clearer in intent than something likelist(x), especially for readers unfamiliar with the idiom. - Reserve deep copies for genuinely nested, mutable structures.
- Document in your own APIs whether functions mutate their inputs — this saves other developers from ever needing to reach for
copydefensively. - Consider immutable data structures (tuples, frozensets, or namedtuples) when you want to eliminate the shallow-vs-deep question entirely, since immutable objects don’t need defensive copying at all.
FAQs
Does copy.copy() work on every Python object? For most built-in types, yes. For custom classes, it works by default via __dict__ copying unless you define __copy__ or __deepcopy__ explicitly.
Is slicing (a[:]) the same as copy.copy(a) for lists? Yes, for lists they produce equivalent shallow copies. Slicing doesn’t work this way for dictionaries or sets, though — you’d use .copy() or dict()/set() constructors there.
Why doesn’t = create a copy? Because Python variables are references, not value containers. Assignment just binds a new name to an existing object.
Does shallow copying improve performance meaningfully for large datasets? Yes, especially when nested mutation isn’t a concern. Avoiding unnecessary deep copies is a common and effective optimization technique.
What about tuples — can I shallow copy them? Tuples are immutable, so copy.copy() on a tuple typically just returns the same object, since there’s no risk of mutation to protect against.
Summary
Shallow copying in Python duplicates the outer container but leaves nested objects shared by reference. This behavior comes directly from how CPython manages objects in memory — variables are pointers, and copying at one level only reassigns which pointers a new container holds. Shallow copies are fast and memory-efficient, but dangerous for nested mutable data, where a deep copy is usually the safer choice. Once I internalized the distinction between assignment, shallow copy, and deep copy, an entire category of bugs disappeared from my code.
References
- Python official documentation:
copymodule - Python official documentation: Data model — object identity
- PEP 3119 and general CPython memory model discussions on docs.python.org
