datarekha
Python Medium Asked at AmazonAsked at GoogleAsked at MicrosoftAsked at Palantir

How does Python's mutable vs immutable distinction affect function arguments and default values?

The short answer

Python passes references to objects, so a mutable argument (list, dict, set) can be modified inside a function and the change is visible to the caller. An immutable argument (int, str, tuple) cannot be mutated in place, so rebinding the local name only affects the local scope. The most common trap is using a mutable object as a default argument value, which is shared across all calls.

How to think about it

The interviewer wants to see the object model underneath the syntax. The single idea that explains everything here: Python always passes a reference to the object. What then decides the behaviour is whether that object can be changed in place.

For immutables — int, str, tuple, frozenset — “mutating” is impossible, so any change actually builds a new object. The function’s local name swings to the new object while the caller’s name still points at the original, and the two quietly diverge. For mutables — list, dict, set, most class instances — a method like .append() changes the object itself, and since the caller and the function hold the same object, the caller sees it.

A worked example

# Mutable: a method call changes the shared object — caller sees it
def append_zero(lst):
    lst.append(0)

data = [1, 2, 3]
append_zero(data)
print("After append_zero:", data)        # [1, 2, 3, 0]

# Immutable: 'n = n*2' rebinds a local name to a NEW int; caller untouched
def double(n):
    n = n * 2
    return n

x = 5
print("x unchanged:", x, "  return value:", double(x))

# Rebinding a mutable doesn't reach the caller either — only the local name moves
def replace_list(lst):
    lst = [99, 98, 97]                    # local name -> brand-new list
    print("inside function:", lst)

numbers = [1, 2, 3]
replace_list(numbers)
print("caller's list:", numbers)         # [1, 2, 3] — unchanged

# The mutable-default trap
def add_item_bad(item, cache=[]):
    cache.append(item)
    return cache

def add_item_good(item, cache=None):
    if cache is None:
        cache = []
    cache.append(item)
    return cache

print(add_item_bad("a"))                 # ['a']
print(add_item_bad("b"))                 # ['a', 'b'] — same list!
print(add_item_good("a"))                # ['a']
print(add_item_good("b"))                # ['b'] — fresh list each call
After append_zero: [1, 2, 3, 0]
x unchanged: 5   return value: 10
inside function: [99, 98, 97]
caller's list: [1, 2, 3]
['a']
['a', 'b']
['a']
['b']

The middle two cases are the ones that untangle the confusion. append_zero mutated the list through the shared reference, so the caller’s data grew. But replace_list rebound its local name to a new list — that move never touched the caller’s numbers, which stayed [1, 2, 3]. Mutating reaches the caller; reassigning does not.

The right name for it

It’s neither “pass by value” nor “pass by reference” — Python is best described as pass by object reference (or pass by assignment). The function gets a copy of the pointer: mutate through it and the caller sees the change; reassign it (lst = something_else) and only your local copy moves while the caller’s pointer stays put.

Learn it properly Errors & Exceptions

Keep practising

All Python questions

Explore further

Skip to content