I remember the exact moment this topic tripped me up. I passed a list into a function, modified it inside, and returned to my main code only to find the original list had changed too — even though I never explicitly returned it. That confusion sent me down a rabbit hole into how Python actually passes arguments, and what I found is that neither “pass-by-value” nor “pass-by-reference” — the two terms most programming courses throw around — fully describes what Python does. Python uses something more precise, often called “pass-by-object-reference” or “pass-by-assignment.” Let me walk you through what that actually means.
Forget “Pass-by-Value” vs “Pass-by-Reference”
In languages like C, “pass-by-value” means a function gets a copy of the data, so changes inside the function don’t affect the caller. “Pass-by-reference” (as in C++ with &) means the function gets direct access to the original variable’s memory location, so changes do propagate back.
Python does neither of these in the traditional sense. Instead, every variable in Python is a name bound to an object, and when I pass an argument to a function, I’m passing a reference to that same object — not a copy of the object, and not a reference to the variable itself. This distinction matters a lot.
def modify(x):
x = x + 1
print("Inside function:", x)
a = 5
modify(a)
print("Outside function:", a)
Output:
Inside function: 6
Outside function: 5
Even though x and a both pointed to the same integer object 5 at the start, reassigning x inside the function just makes x point to a new object (6). It doesn’t affect what a points to. This is where the “value” behavior comes from — but it’s really about immutability, not copying.
The Real Factor: Mutability, Not “Reference vs Value”
The behavior I just showed has almost nothing to do with how arguments are passed and everything to do with whether the object itself is mutable or immutable.
- Immutable types:
int,float,str,tuple,bool,frozenset— these can’t be changed in place. Any “modification” actually creates a new object. - Mutable types:
list,dict,set, and most custom objects — these can be changed in place, and those changes are visible to every name pointing at that object.
Let’s see the mutable case in action:
def modify_list(lst):
lst.append(4)
print("Inside function:", lst)
my_list = [1, 2, 3]
modify_list(my_list)
print("Outside function:", my_list)
Output:
Inside function: [1, 2, 3, 4]
Outside function: [1, 2, 3, 4]
Here, lst inside the function and my_list outside both point to the exact same list object in memory. Calling .append() mutates that shared object, so the change is visible everywhere.
Verifying This with id()
I like to prove this to myself using the built-in id() function, which returns a unique integer identifying an object’s location in memory (specifically, on CPython, its memory address).
def show_id(x):
print("Inside function id:", id(x))
value = [1, 2, 3]
print("Outside function id:", id(value))
show_id(value)
Output (the exact numbers will differ on your machine):
Outside function id: 140234581923648
Inside function id: 140234581923648
Same ID — same object. This confirms that Python passed a reference to the existing list, not a new copy.
Reassignment vs Mutation: The Key Distinction
This is the single most important mental model I use now whenever I’m reasoning about argument behavior in Python:
- Reassignment (
x = something_new) changes what the local namexpoints to. It does not affect the caller’s variable. - Mutation (
x.append(...),x[0] = ...,x.update(...)) changes the object itself. Since the caller’s variable points to that same object, the caller sees the change.
def reassign(lst):
lst = [9, 9, 9] # reassignment — creates a new local binding
print("Inside:", lst)
def mutate(lst):
lst[0] = 9 # mutation — modifies the existing object
print("Inside:", lst)
original = [1, 2, 3]
reassign(original)
print("After reassign:", original) # [1, 2, 3] — unchanged
mutate(original)
print("After mutate:", original) # [9, 2, 3] — changed
Common Pitfall: Mutable Default Arguments
This same mutability rule creates one of Python’s most infamous gotchas, which I’ve fallen into personally: using a mutable object as a default function argument.
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['apple', 'banana'] <- surprising!
I expected a fresh empty list every call, but Python only evaluates default argument values once, at function definition time — not each time the function is called. That single list object persists across calls, accumulating state I never intended to share. I go into the fix for this in detail in my [Defining a Function with Optional Mutable Arguments guide], but the short version is to use None as the sentinel default and create the mutable object fresh inside the function body.
Passing Immutable Objects into Functions That “Look” Mutating
Strings are a great example of an immutable type that can trip people up because string methods often look like they mutate:
def shout(text):
text = text.upper()
return text
message = "hello"
shout(message)
print(message) # still "hello"
text.upper() doesn’t change the original string in place — it can’t, because strings are immutable. It creates and returns a brand-new string object, which I then reassigned to the local name text. The caller’s message variable was never touched.
Function Arguments and the Symbol Table
Internally, when I call a function, Python creates a new local namespace (a dictionary-like symbol table) for that function’s frame. Each parameter name in that namespace is bound to the same object passed in by the caller — Python doesn’t copy the object, it copies the reference. I can visualize this using id() on multiple variables:
def f(a):
print(id(a))
x = [1, 2, 3]
print(id(x))
f(x)
Both id() calls print the same number, confirming the shared object reference. If I then reassign a inside f, I’m just changing what that local slot in the function’s namespace points to — the caller’s namespace is completely separate and unaffected.
Deliberately Avoiding Mutation: Copying
Sometimes I want to pass an object into a function without risking mutation of the original. Python gives me a few tools for this:
import copy
original = [1, [2, 3], 4]
shallow = original.copy() # or list(original), or original[:]
deep = copy.deepcopy(original)
A shallow copy duplicates the outer container but still shares references to nested mutable objects — so mutating a nested list inside a shallow copy will still affect the original. A deep copy recursively duplicates everything, so nothing is shared.
shallow[1].append(99)
print(original) # [1, [2, 3, 99], 4] — nested list affected!
deep[1].append(100)
print(original) # unaffected, since deep copy is fully independent
If I want a function to be “pure” (no side effects on its inputs), I explicitly copy mutable arguments at the top of the function before touching them.
Performance Implications
Because Python passes references rather than copies, calling a function with a huge list or dictionary is cheap — Python doesn’t duplicate megabytes of data just to pass it around. This is one of the underrated performance benefits of Python’s object model. The cost only shows up if I explicitly copy the object, which is why I only reach for copy.deepcopy() when I genuinely need isolation, not as a defensive habit.
Best Practices I Follow
- I avoid mutating arguments inside a function unless that mutation is clearly the function’s documented purpose (like
list.sort()or.append()helpers). - When a function needs to transform data, I prefer returning a new object rather than mutating the input, since it makes the function’s behavior more predictable and easier to test.
- I never use mutable default arguments.
- When I genuinely need to protect a caller’s data from being modified, I use
copy.deepcopy()explicitly rather than assuming Python will protect me.
FAQs
Is Python pass-by-value or pass-by-reference? Neither, exactly. Python is best described as “pass-by-object-reference” — the function receives a reference to the same object the caller has, but reassigning that reference inside the function doesn’t affect the caller.
Why did my list change after I passed it into a function, but my integer didn’t? Because lists are mutable and integers are immutable. Mutating a shared mutable object is visible everywhere it’s referenced; reassigning a name to a new object (which is all you can do with immutable types) is not.
How can I pass a variable so a function can’t modify it? Pass an immutable type, or pass a copy (copy.deepcopy() for nested structures) of a mutable type.
Does using *args or **kwargs change these mutability rules? No — arguments collected via *args or **kwargs follow exactly the same reference-passing rules as normal parameters.
Summary
Python’s argument-passing model is often mislabeled, but once I stopped trying to force it into “pass-by-value” or “pass-by-reference” boxes and instead focused on mutability, everything clicked. Every argument is a reference to an object; whether changes inside a function are visible to the caller depends entirely on whether that object is mutable and whether the function mutates it in place versus rebinding the local name to something new. Internalizing this distinction has saved me from a whole category of subtle bugs.