What Is Aliasing in Python? The Shared-Reference Bug That Empties Your Results

- Aliasing in Python happens when two variables reference the same mutable object; mutation through one variable is visible through all others
- The backtracking bug:
result.append(current)stores a live reference, not a snapshot — fix it withresult.append(current[:]) - Mutable default arguments accumulate state across calls; use
Noneas the default and initialize inside the function body - Shallow copy duplicates the container but shares nested objects; reach for
copy.deepcopyonly when elements are themselves mutable containers - Copy cost counts in interviews: appending a copy at every backtracking leaf is O(n × 2^n) — interviewers expect you to include it in your complexity analysis
- Not Python-only: JavaScript, TypeScript, Java, Kotlin, and Go all use reference semantics for objects — the same aliasing bug appears in all of them
You write a backtracking solution. It runs. The output is [[], [], [], [], [], []]. Every element is an empty list. No error. No stack trace. Your logic looks correct.
You run it again. Still empty. You add a print statement. Inside the function, everything looks fine. You close your laptop. You open it again.
The bug is aliasing in Python. You appended the same list object eight times instead of eight copies of it.
Aliasing is when two or more variables point to the same object in memory. Mutate the object through one variable and every other variable that points to it reflects the change, because there is only one object.
One Object, Many Names
In Python, assignment never copies an object. It copies a reference.
a = [1, 2, 3] b = a
There is one list on the heap. a and b are two names for it. When you write b.append(4), the list itself changes, and a now sees [1, 2, 3, 4].
b.append(4) print(a) # [1, 2, 3, 4] print(b) # [1, 2, 3, 4] print(a is b) # True, same object
The is operator confirms it. Contrast that with integers:
x = 42 y = x y += 1 print(x) # 42, unchanged
No aliasing here because integers are immutable. When you write y += 1, Python creates a new integer object and rebinds y. The original 42 is untouched.
The aliasing bug only fires when the shared object is mutable. Lists, dicts, sets, and most class instances are mutable. Integers, strings, tuples, and frozensets are not.
Python's Aliasing Memory Model
Python variables are not boxes. They are labels on objects.
When you write a = [1, 2, 3], Python allocates a list on the heap and stores the memory address in a. When you write b = a, Python copies that address into b. Two labels, one object. When you write b = a[:], Python creates a new list with the same contents and stores that address in b. Two labels, two objects.

This model is close to C pointers, except Python hides the pointer syntax entirely. C++ works the opposite way: assignment copies by default. std::vector<int> b = a; gives you two independent vectors. C++ programmers writing Python make aliasing bugs. Python programmers writing C++ make unintentional-copy bugs. Everyone is wrong about something.
Where It Destroys Interview Solutions
Backtracking
This is the most common interview aliasing failure. The path variable accumulates elements, gets appended to a results list, then gets cleaned up. By the end, the path is empty and every entry in results points to that same empty list.
def subsets(nums): result = [] def backtrack(start, current): result.append(current) # Wrong: appending the reference for i in range(start, len(nums)): current.append(nums[i]) backtrack(i + 1, current) current.pop() backtrack(0, []) return result
Run this on [1, 2, 3]. You get eight empty lists. The fix is one character:
result.append(current[:]) # Append a copy
Now each call appends a fresh list object containing whatever is in current at that moment. Subsequent pops and appends don't touch those snapshot copies.
You've been staring at this for twenty minutes. The algorithm is correct. The indices are correct. The recursion terminates correctly. Your career is the only thing that might not survive.

The aliasing bug: technically visible in the code, completely invisible to your brain at 2am.
Graph and Tree Path Tracking
The pattern repeats in any DFS that accumulates a path.
def all_paths(graph, start, end): paths = [] def dfs(node, path): if node == end: paths.append(path) # Same bug return for neighbor in graph[node]: path.append(neighbor) dfs(neighbor, path) path.pop() dfs(start, [start]) return paths
Every entry in paths is the same list object. Fix: paths.append(path[:]).
Passing Mutable Defaults
Python evaluates default argument values once, at function definition time. This sounds totally reasonable. It is the kind of fact that sounds reasonable right up until it ruins your day.
def add_item(item, collection=[]): # Default list created once collection.append(item) return collection print(add_item(1)) # [1] print(add_item(2)) # [1, 2], not [2]
The same list object is reused across every call that omits the argument. The fix:
def add_item(item, collection=None): if collection is None: collection = [] collection.append(item) return collection
Shallow Copy vs Deep Copy
There are two ways to copy an object, and the difference matters more than you'd expect.
Shallow copy creates a new container but does not copy the elements inside it. Those elements still point to the same objects as the original.
a = [[1, 2], [3, 4]] b = a[:] # Shallow copy b[0].append(99) print(a) # [[1, 2, 99], [3, 4]] (inner list still shared)
The outer list b is a new object. But b[0] and a[0] are still the same inner list. You did copy. You just copied the wrong thing.
Deep copy recursively copies every nested object.
import copy b = copy.deepcopy(a) b[0].append(99) print(a) # [[1, 2], [3, 4]], fully independent
For the backtracking pattern, a flat list of integers only needs a shallow copy. current[:] is sufficient. You only need deepcopy when the container holds other mutable containers. Reaching for deepcopy on a flat list is the kind of overcorrection that costs you interview points on complexity analysis.
Shallow copy methods for a flat list:
b = a[:] # Slice b = list(a) # Constructor b = a.copy() # Method b = [*a] # Unpacking
All four are O(n) in time and space. There is no way to copy n elements in less.

Shallow copy, deep copy, deepcopy of a deepcopy... Python is very normal about memory.
The Time and Space Cost
Copying is not free. A shallow copy of a list of length k is O(k) time and O(k) space. In backtracking, you copy at every leaf of the recursion tree.
For subsets of an n-element array, there are 2^n leaves and each copy is at most O(n). The total copying cost is O(n x 2^n), which matches the traversal itself, so the asymptotic bound doesn't change. But it does double the constant, and many candidates give the traversal complexity and forget the copy cost entirely.
An interviewer asking "what's the time complexity?" expects both. You would be surprised how often candidates who solved the problem correctly lose points here.
One option that sidesteps a full copy is converting to a tuple:
result.append(tuple(current))
Tuples are immutable, so there is no aliasing risk. Creation is still O(n), but tuples are slightly more memory-efficient than lists in CPython.
Aliasing in Other Languages
The same model applies in JavaScript, TypeScript, Java, Kotlin, Go, and most other languages with reference semantics for objects.
In TypeScript:
const a = [1, 2, 3]; const b = a; b.push(4); console.log(a); // [1, 2, 3, 4]
To copy: const b = [...a] or const b = a.slice().
In Java:
List<Integer> a = new ArrayList<>(List.of(1, 2, 3)); List<Integer> b = a; // alias b.add(4); System.out.println(a); // [1, 2, 3, 4]
To copy: List<Integer> b = new ArrayList<>(a);
Go slices add a layer. A slice is a header (pointer, length, capacity) over a backing array. Assigning one slice copies the header, not the array, so whether mutations through b affect a depends on whether append reallocates. The safe copy pattern:
b := make([]int, len(a)) copy(b, a)
Spotting the Bug Before You Submit
Aliasing bugs produce wrong answers without crashes. Your intuition says the logic is correct because the algorithm is correct. The output is just wrong. It is very hard to feel bad about code that has no errors and correct-looking logic, which is exactly why this bug survives so long.
Find every result.append(...) call inside a recursive function. Ask whether the argument is a mutable container that gets modified later in the recursion. If yes, append a copy.
Then trace through a small example manually. After all recursion ends, check what each element of result actually is. All identical or all empty means aliasing.
If you want to practice catching this under pressure, SpaceComplexity runs voice-based mock interviews where the AI interviewer will probe your complexity analysis and ask follow-up questions when your output looks wrong. Catching the aliasing bug out loud, before the interviewer points it out, is one of the clearest signals that you understand what your code is actually doing.
Key Takeaways
- Two or more variables point to the same object. Mutate through one, all of them change.
- Mutable objects (lists, dicts, sets) carry aliasing risk. Immutable ones don't.
- The fix in backtracking:
result.append(current[:]), notresult.append(current). - Shallow copy is O(n) and enough for flat lists. Use
deepcopyonly when you have nested mutables. - Count the copy cost in your complexity analysis. For subsets backtracking it's O(n x 2^n).
Further Reading
- Aliasing (computing), Wikipedia
- Python Data Model: Objects, values and types
- copy, Shallow and deep copy operations, Python docs
- MDN: Mutable
- GeeksforGeeks: Python Shallow Copy and Deep Copy
Also relevant: Pass by Value vs Reference in Python, Mutable vs Immutable in Python, Backtracking Bugs, What Is a Pointer vs a Reference?