Pass by Value vs Reference in Python: Why Your Backtracking Returns Empty Lists

May 26, 20269 min read
dsaalgorithmsinterview-prepleetcode
Pass by Value vs Reference in Python: Why Your Backtracking Returns Empty Lists
TL;DR
  • Call by sharing (Python, Java, JavaScript) passes a copy of the reference: mutations to the shared object are visible to the caller, but reassignments are not.
  • Backtracking snapshot bug: result.append(path) stores a live reference; use result.append(path[:]) to capture state at each leaf.
  • 2D list multiplication ([[0]*cols]*rows) creates aliased rows; a list comprehension creates independent ones.
  • Go slice append: the slice header is passed by value, so append that reallocates is invisible to the caller; return and reassign instead.
  • Aliasing bug signature: values look correct inside the recursion but collapse in the final result; find the reference that outlived its snapshot.
  • True pass by reference (C++ T&) is the only model where reassignment inside a function changes the caller's variable.
  • Rust encodes the policy at compile time via Copy types, moves, and borrows, making aliasing bugs impossible.

You run your backtracking solution. Tests pass on the simple case. You add a few more elements, print the result, and see this:

[[], [], [], [], [], [], []]

Seven empty lists. You stored seven things. They are all the same empty list. Somehow you have invented a data structure that represents nothing, seven times.

The path was definitely there during recursion. You added prints. The values showed up. And then, by the time you printed result, they were gone. Nothing escaped. Your code collected references to one mutable list and then watched that list drain as the recursion unwound. By the end, every entry in result points at the same empty path.

Classic aliasing bug. Let's make sure you only write it once.

Your Variables Are Sharing an Apartment

Before the fix, get the mental model right. There are three actual behaviors in the world, and one of them is surprisingly rare.

Pass by value means the callee gets a photocopy. Anything it does to the copy does not touch the original.

Pass by reference (real C++ & style) means the callee gets the original variable directly. If it reassigns the parameter, the caller sees the new value.

Everything else, which includes Python, Java, JavaScript, and Go for its reference types, is a third thing. Barbara Liskov named it "call by sharing" in 1974 when she designed the CLU language. You pass a copy of the reference, not a copy of the object. Think of it as handing someone a spare key, not moving them into your house and not signing over the deed. They can rearrange the furniture (mutation). They cannot change whose name is on the lease (reassignment).

Caller and callee both hold references to the same heap object Two frames, one object. Both references aim at the same list. Mutate through either one and both sides see it. Reassign the callee's local name and the caller's arrow does not move.

Two pointers. One list. That is the whole model.

Who Actually Copies What

Python. Integers, strings, tuples, frozensets: immutable. "Changing" one inside a function creates a new object and rebinds the local name. Lists, dicts, sets: mutable. .append, .clear, d[k] = v are all visible to the caller. param = [] is not.

Java. Always pass by value. For primitives, the value is the bits. For objects, the value is the reference. You can mutate through the copy. You cannot make the caller's variable point somewhere else.

JavaScript. Same model as Java. The MDN docs say "pass by value" and mean it, but for an object the value is its memory address.

C++. You choose. No qualifier means a full copy is made at the call site. const T& gives a read-only alias with no copy overhead. T& gives a writable alias, true pass by reference. For STL containers, always use const T& for read parameters. Passing a vector<int> by value in a hot loop copies the heap allocation on every call.

Go. A slice is a three-field struct: pointer to the backing array, length, capacity. Passing a slice copies the header, not the array. Element mutations are visible to the caller. append that reallocates creates a new backing array, visible only to the callee.

Rust. The borrow checker makes this explicit. Copy types duplicate. Everything else is moved or borrowed. &T for shared read, &mut T for exclusive write. The whole class of bugs in this article is a compile error in Rust.

Language extremes and the compromise column nobody asked for The "pass by value vs pass by reference" debate, compressed into a table. The compromise row at the bottom explains a lot about why this confuses people.

Three Bugs, Three Different Pain Points

The Backtracking Snapshot Bug

Your backtracking solution finds the right leaves. Then you store the live path instead of a snapshot.

def subsets(nums): result = [] path = [] def backtrack(start): result.append(path) # BUG: appends a reference to path for i in range(start, len(nums)): path.append(nums[i]) backtrack(i + 1) path.pop() backtrack(0) return result # every entry points at the same empty list

After the recursion finishes, path is empty because everything got popped. Every entry in result is a reference to that same object. Fix it with a shallow copy at every result collection site.

result.append(path[:]) # snapshot, not a reference

path[:] constructs a new list with the same elements at that moment. The two lists are independent from then on.

Before and after the snapshot fix in backtracking Left: every result entry points at the same path object, which empties on unwind. Right: each entry is an independent copy taken at the right moment.

The 2D Grid Initialization Trap

You need a DP table. You write one line. Python nods and creates one inner list and three illusions.

dp = [[0] * cols] * rows

This creates one inner list and makes rows references to it. Set one cell and you set the whole column.

dp = [[0] * 3] * 3 dp[0][0] = 1 # dp is [[1, 0, 0], [1, 0, 0], [1, 0, 0]] # You also changed dp[1][0] and dp[2][0]

Use a list comprehension. Each iteration constructs a fresh list.

dp = [[0] * cols for _ in range(rows)]

List multiplication vs list comprehension for 2D table initialization Multiplication gives you one inner list with multiple names. Comprehension gives you independent rows.

The Go Slice Append Surprise

func addToSlice(s []int, x int) { s = append(s, x) // caller never sees x if append reallocates } nums := []int{1, 2, 3} addToSlice(nums, 4) fmt.Println(nums) // [1 2 3]

The slice header (pointer, length, capacity) was copied into addToSlice. The function updated its local copy's length and may have gotten a new backing array. The caller's header is untouched. To grow a slice inside a function, return it and reassign at the call site.

func addToSlice(s []int, x int) []int { return append(s, x) } nums = addToSlice(nums, 4)

Mutating elements within the existing length works fine without returning, since both headers point to the same backing array.

Go slice header copied by value, backing array shared Caller and callee share the same backing array until append reallocates. After reallocation, the callee's pointer moves to a new array. The caller sees nothing.

Mutation or Reassignment?

When you are not sure whether an operation escapes a function, ask two questions.

First: is it a mutation or a reassignment? Mutations like .append, .clear, d[k] = v, and index assignment act on the shared heap object. Reassignments like param = something_new rebind the local name. The caller sees mutations. The caller does not see reassignments.

Second: does the language have true pass by reference? Only C++ T& parameters give you reassignment visibility. In Python, Java, and JavaScript, no reassignment inside a function ever changes the caller's variable.

If the operation is a mutation and the type is mutable, assume the caller sees it. If it is a reassignment, assume the caller does not.

One more pattern worth naming: functions that take a results list as a parameter and append to it are using mutation on purpose. That is fine. Problems appear when you append the same mutable container you are still modifying, rather than a copy of its current state.

This Bug Has a Smell

Aliasing bugs have a signature. The data looks correct during traversal but wrong in the final result. Add a print inside the recursion and the values are there. Print result at the end and the entries are empty or identical.

If values existed at some point and then vanished or collapsed, look for a reference that outlived its expected snapshot.

The backtracking algorithm post walks through the choose/explore/undo structure in detail. Every "undo" step is a mutation. If you snapshotted correctly, the undo affects only the live working state. If you stored a reference, the undo silently corrupts your collected results too.

The same issue comes up in iterative tree traversal: store node objects and mutate them later, and you are in the same trap. Store values, not handles to mutable things.

The recursion space complexity framing is relevant here too. Call stack frames hold local variables and return addresses. Heap-allocated containers live beyond any individual frame. That is what makes aliasing both useful and dangerous: a shared accumulator is intentional. A shared result you forgot was shared is a bug.

If you want to catch this under pressure, SpaceComplexity runs voice-based mock interviews where the interviewer asks follow-up questions specifically about mutation choices. "What happens to that list if we call this function twice?" That is a much harder question to answer when someone is watching the clock.

The Short Version

  • Call by sharing: you pass a copy of the reference. Mutations are visible. Reassignments are not.
  • True pass by value: the data is copied. Nothing the callee does reaches the caller.
  • True pass by reference (C++ &): the variable itself is aliased. Reassignments are visible.
  • Python, Java, JavaScript: call by sharing. Immutable types behave like value types because mutation is impossible.
  • Go slices: header by value, backing array shared. Append that reallocates is invisible to caller. Element mutation is visible.
  • Rust: the type system encodes the policy. Copy types duplicate. Non-copy types move or borrow.
  • In backtracking: snapshot with path[:] or list(path) at every result collection site, not a live reference.
  • In 2D initialization: list comprehension creates independent rows. List multiplication creates aliased rows.

Further Reading