Copying lists in Python is where the language's reference semantics bite hardest. Assignment does not copy anything, a shallow copy copies only the outermost container, and a deep copy recurses all the way down. Each of the three is right for different situations, and picking the wrong one produces bugs that look like nothing is happening.
What does the = sign do to a list?
It binds a second name to the same list. Python lists are objects, and a name is a label rather than a container. Two names can point at one object, and any change made through either name is visible through the other.
first = [1, 2, 3]
second = first
second.append(4)
print("first:", first)
print("second:", second)
print("same object:", first is second)
Nothing was copied. first and second are two labels on one list, and the append changed it for both of them. If that is what you wanted — two variables pointing at shared state — the assignment is the correct tool. If you wanted two independent lists, it is the bug.
The difference between is and == is worth pinning down now. == asks whether two objects hold equal values, and is asks whether they are the same object. Two separately built empty lists are equal but not identical.
print([] == [])
print([] is [])
print([1, 2] == [1, 2])
What is the difference between a shallow copy and a deep copy?
lst.copy() and copy.copy(lst) do the same thing: they build a new list containing the same elements. The elements themselves are not duplicated. If an element is a mutable object, both lists point at that one object.
original = [1, 2, 3]
duplicate = original.copy()
duplicate.append(4)
print("original:", original)
print("duplicate:", duplicate)
That handles the flat case perfectly. The moment the elements are themselves mutable, the shallow copy starts to leak. Adding to a list inside the copy changes the list inside the original, because there is only one of them.
import copy
settings = {"tags": ["python"]}
shallow = copy.copy(settings)
shallow["tags"].append("web")
print("original after shallow change:", settings)
copy.deepcopy is the answer when the inner objects must be independent. It walks the whole structure and recreates everything it finds, including dicts, lists, sets and custom objects, so nothing is shared with the source.
import copy
settings = {"tags": ["python"]}
deep = copy.deepcopy(settings)
deep["tags"].append("web")
print("original after deep change:", settings)
print("deep copy:", deep)
Use the shallow version whenever sharing the inner objects is intentional, because a deep copy of a large structure is not free — it allocates and rebuilds every object it walks. A practical rule: copy shallow first, and reach for deepcopy only when you have actually observed a leak.
Deepcopy also has limits worth knowing. It knows how to rebuild containers and ordinary objects, but it cannot duplicate things that represent external resources, such as an open file handle, a database connection or a thread lock, so those end up shared even in a deep copy. If your structure contains one of those, copying it is a design question, not a call you can make blind.
Why does the [[0] * 3] * 3 trick fail?
Because * 3 repeats a reference, not a value. The inner list is built once, and the outer list is three references to that one list. Setting an element in one row changes all three.
grid = [[0] * 3] * 3
grid[0][0] = 99
print(grid)
The correct construction uses a list comprehension, which runs the inner expression once per row and therefore produces three separate lists.
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 99
print(grid)
This is the same reference rule as a = b, just wearing a different hat. It is worth recognising because the failure is quiet: the grid looks right until you write to it, and then it looks wrong in a way that suggests a maths problem rather than an aliasing problem.
How do I check whether two names point at the same list?
Use is. The id() builtin is the underlying mechanism — it returns the identity of an object — and printing two ids is a good way to see what is happening when a copy is not behaving as you expect.
a = [1, 2, 3]
b = a
c = a.copy()
print("id(a):", id(a))
print("id(b):", id(b))
print("id(c):", id(c))
print("a is b:", a is b)
print("a is c:", a is c)
You will see different numbers for a and c and the same number for a and b, which is exactly the distinction the lesson is about. In real code, reach for is rather than comparing ids by hand.
How does passing a list to a function affect the caller?
Exactly like assignment: the argument is a reference to the same list. If the function mutates the list, the caller sees it. If the function rebinds its own parameter name, the caller sees nothing.
def add_item(items):
items.append("new")
return items
original = ["start"]
result = add_item(original)
print("original:", original)
print("result:", result)
def replace_items(items):
items = ["replaced"]
return items
untouched = ["start"]
replace_items(untouched)
print("untouched:", untouched)
The first function reaches inside the list the caller owns and changes it. The second creates a new list and binds it to its own local name, so the caller's list is untouched. This is why functions that mutate their arguments are worth avoiding: the effect is invisible in the signature and shows up somewhere else entirely.
If you want a function to work on a copy instead of the original, copy at the top of the function, or copy at the call site. Deciding where the copy happens tells the reader where the mutation stops.
Do tuples avoid the problem?
Partly. A tuple itself cannot be mutated, so there is no append to leak. But a tuple can hold mutable objects, and those are shared exactly as they were before.
import copy
original = ([1, 2],)
alias = original
alias[0].append(3)
print("original after alias change:", original)
fresh = copy.deepcopy(original)
fresh[0].append(4)
print("original after deep change:", original)
print("deep copy:", fresh)
So the rule stays the same. Immutability of the container protects the container's own slots; it says nothing about the objects sitting in those slots. This is also why "use a tuple to make it safe" is not a complete answer to the aliasing question.
Copying cheat sheet
| Expression | New outer object? | Inner objects shared? |
|---|---|---|
b = a | no | yes |
b = a.copy() | yes | yes |
b = copy.copy(a) | yes | yes |
b = copy.deepcopy(a) | yes | no |
b = a[:] | yes | yes |
b = list(a) | yes | yes |
Try it yourself: make a list, point a second name at it, change it through the second name and print both. Then do the same with .copy() and with copy.deepcopy(), using a list of lists so you can see exactly where the shallow copy stops protecting you. Build a three-by-three grid the wrong way and then the right way, and write in it to show the difference. Finally, write a function that appends to its argument, call it with a list, and comment on why the caller's list changed — then rewrite the function to copy first and confirm the caller is unaffected.