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.

StatementWhat it doesVisible through original?
`alias.append(4)`Mutates the shared listYes
`alias[0] = 99`Replaces a slot in the shared listYes
`del alias[0]`Removes a slot from the shared listYes
`alias = [7, 8]`Points `alias` at a new listNo
`del alias`Deletes the name `alias` onlyNo

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 tags

Predict the output.

week = [[10, 12], [8]]
snapshot = week[:]
snapshot.append([5])
snapshot[0].append(99)
print(len(week), week[0])

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.