How to pass a variable by reference in Python

Python has neither pass-by-value nor pass-by-reference, and the usual explanation — “mutable types are passed by reference, immutable ones by value” — is close enough to sound right and wrong enough to mislead. This article takes it apart.

The observation everyone starts from

def try_rebind(x):
    x = 99

def try_mutate(x):
    x.append(99)
int after rebinding inside a function  : 1      <- unchanged
list after .append inside a function   : [1, 99]  <- changed

From which it looks as though lists and ints are passed differently. They are not.

The same list, both ways

def rebind_list(x):
    x = [99]

def mutate_list(x):
    x.append(99)
after rebind_list(a) : [1]        <- unchanged
after mutate_list(a) : [1, 99]    <- changed

Same type, same signature, different outcome. So the distinction is not list-versus-int. It is rebinding versus mutating.

What actually happens

Nothing is copied. The parameter becomes a second name for the same object:

id(obj) outside     : 140234...
id of the parameter : 140234...
same object?        : True

Assignment inside the function binds that local name to something else. The caller’s name is untouched, because it was never the same name — only the object was shared:

id inside  : 140234...
id after rebinding inside : 140891...   <- a different object
outside is still : 'original'

This is sometimes called call by assignment: passing an argument works exactly like an assignment statement, and assignment in Python binds names to objects rather than copying values into boxes.

Once you see it that way, the mutable/immutable business falls out as a consequence rather than a rule. Immutable objects cannot be changed in place, so mutating them is not an option:

tuple[0] = 9  -> TypeError: 'tuple' object does not support item assignment
str[0] = 'z'  -> TypeError: 'str' object does not support item assignment

Only rebinding is available, and rebinding never reaches the caller. Hence the appearance of pass-by-value for int, str and tuple.

Where += stops making sense

def plus_equals_list(x):
    x += [99]

def plus_equals_int(x):
    x += 1
list after x += [99] inside : [1, 99]   <- CHANGED
int after x += 1 inside     : 1         <- unchanged

Identical syntax, opposite results. += tries __iadd__ first, which mutates in place and returns the same object. list has one, so x += [99] is x.extend([99]) and the caller sees it. int does not, so Python falls back to x = x + 1, which is a rebinding.

So += is a mutation or a rebinding depending on the type on the left. That is worth knowing before you use it on a parameter.

The trap that does both

The best demonstration of the above, and a genuinely odd one:

t = ([1], 2)
t[0] += [9]
t[0] += [9] on ([1], 2) -> TypeError: 'tuple' object does not support item assignment
...and yet t is now ([1, 9], 2)   <- the mutation HAPPENED before the error

It raised and it worked. += on the inner list succeeded via __iadd__, mutating it in place. Then Python tried to store the result back into t[0], which a tuple refuses — but the list had already changed.

If you ever wanted proof that += is two operations rather than one, this is it.

How to get a value back out

There is no & or ref in Python. Three options, in the order I would reach for them.

Return it. Almost always the right answer:

b = returns_it(b)
return a new value and rebind : [1, 99]

Mutate a container you were given. Fine when the function’s job is to fill something in, though it makes the function harder to reason about.

Wrap it in an object:

class Box:
    def __init__(self, value): self.value = value

def set_box(box):
    box.value = 99
wrap it in an object : 99

Attribute assignment is a mutation of the object, not a rebinding of the name, so it reaches the caller.

The consequence you will meet first

Default arguments are evaluated once, at definition time, so a mutable default is shared by every call:

def collect(item, into=[]):
    into.append(item)
    return into
first  call : ['a']
second call : ['a', 'b']   <- the SAME list

The fix is the standard one:

def collect_ok(item, into=None):
    if into is None:
        into = []
with None : ['a'] then ['b']

When you do not want to share

list(orig)[0] is orig[0]        : True
deepcopy(orig)[0] is orig[0]    : False

list(x), x[:] and x.copy() all copy one level. If the function might mutate something nested, only copy.deepcopy protects the caller.

About Netcup (advertisement)

The German host Netcup offers, among other things, affordable and powerful web hosting packages, KVM-based root servers and dedicated servers. With our voucher codes you can save even more (6€ off your first order, 30% off all KVM-based root servers, ...).