References and Shallow Copies
Concepts covered: Copying Lists
A variable holds a reference to a list object stored elsewhere in memory. The statement b = a copies the reference, so afterwards both names reach the same object, and a change made through either name is visible through the other. You already know the tool that reveals this: is compares identity and returns True when 2 names point at a single object.
original = [1, 2, 3]
alias = original
alias.append(4)
alias[0] = 99
print("original:", original)
print("alias:", alias)
print("same object:", alias is original)original: [99, 2, 3, 4] alias: [99, 2, 3, 4] same object: True
The append and the item assignment both went through alias, and original shows both, because there was only ever a single list. Python never copies a list unless you ask it to.
Mutating the object vs rebinding the name
Calling a method changes the object, and so does assigning to an item or running del on one, so every name for it sees the change. A plain name = ... assignment looks similar but only points that single name at a different object, and it leaves the old object and its other names alone.
| Statement | What it does | Visible through original? |
|---|---|---|
| `alias.append(4)` | Mutates the shared list | Yes |
| `alias[0] = 99` | Replaces a slot in the shared list | Yes |
| `del alias[0]` | Removes a slot from the shared list | Yes |
| `alias = [7, 8]` | Points `alias` at a new list | No |
| `del alias` | Deletes the name `alias` only | No |
Functions get the caller's list
Passing a list to a function binds the parameter to the object the caller holds. No copy happens at the call. A cleaning helper that edits its argument therefore edits the caller's data, which is how a raw batch kept for auditing ends up already cleaned.
def drop_empty(rows):
while "" in rows:
rows.remove("")
return rows
raw = ["u1", "", "u2", ""]
clean = drop_empty(raw)
print(clean)
print(raw)
print(clean is raw)Output:
['u1', 'u2'] ['u1', 'u2'] True
clean is raw prints True: the helper returned the very list it was given. Whether that's a bug depends on whether the caller still needed the raw rows.
Shallow copies: a new outer list
a.copy() gives you a new list. a[:] and list(a) do the same: each creates a new outer list and fills it with the references the original holds. For a flat list of numbers or strings that is all the independence you need, because those items can only be replaced, never changed in place. Replacing a slot in the copy leaves the original's slot alone. Items that are themselves lists get shared between the copy and the original.
config = [["us", "eu"], 30]
backup = config.copy()
backup[1] = 60
backup[0].append("apac")
print("config:", config)
print("backup:", backup)
print("outer shared:", config is backup)
print("inner shared:", config[0] is backup[0])config: [['us', 'eu', 'apac'], 30] backup: [['us', 'eu', 'apac'], 60] outer shared: False inner shared: True
backup[1] = 60 replaced a slot in the new outer list, so config[1] is still 30. backup[0].append("apac") followed the reference in slot 0 into the inner list both outer lists point at, so config shows "apac" too. The last 2 lines confirm it: separate outer lists, a shared inner list.
Multiplying a list of lists
Multiplying a list repeats its references. [0] * 3 repeats a reference to 0 3 times, which is harmless because a slot holding 0 can only be replaced. [[0] * 3] * 3 repeats a reference to the same inner list 3 times, so a change to any row shows in every row. A comprehension evaluates [0] * 3 anew for each row, so each row is its own list.
bad = [[0] * 3] * 3
bad[0][0] = 1
print(bad)
good = [[0] * 3 for _ in range(3)]
good[0][0] = 1
print(good)Output:
[[1, 0, 0], [1, 0, 0], [1, 0, 0]] [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
Default arguments are built once
Python evaluates a default value once, when def runs. Every call that omits the argument receives that same object, so a list default collects items across calls.
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
print(add_tag("new"))
print(add_tag("vip"))
print(add_tag("trial"))Output:
['new'] ['new', 'vip'] ['new', 'vip', 'trial']
The fix defaults to None and builds the list inside the function, so each call that omits tags gets a new empty list.
def add_tag(tag, tags=None):
if tags is None:
tags = []
tags.append(tag)
return tagsPredict the output.
week = [[10, 12], [8]]
snapshot = week[:]
snapshot.append([5])
snapshot[0].append(99)
print(len(week), week[0])- 2 [10, 12]
- 3 [10, 12, 99]
- 2 [10, 12, 99]
- 3 [10, 12]
The None default fixes the shared list for a concrete reason. Every call that omits the argument still receives the same default object, but None is never mutated. The [] sits inside the function body, and the body runs on every call, so each call that reaches tags = [] builds a separate list.
About this lesson
This section is part of the Lists: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.