If you have ever searched "is Python pass by reference?" you are not alone. It is one of the frequently searched Python questions on the internet, and for good reason. Developers coming from C++, Java, or C# carry mental models of "pass by value" and "pass by reference" that do not cleanly map onto what Python actually does. The real answer is more nuanced, more elegant, and once you understand it, it will change how you think about every line of Python you write.
Python uses a mechanism called pass by object reference, sometimes called pass by assignment or call by sharing. In this article we are going to take this mechanism apart with real code, trace it back to its academic origins, examine it at the bytecode level using CPython's dis module, connect it to the official Python documentation and relevant PEPs, and build the mental model you actually need. No hand-waving. No copy-paste. Let's crack it.
The Question Everyone Gets Wrong
Ask a room full of developers whether Python is pass by value or pass by reference and you will get confident answers on both sides. The developers who say "pass by value" will point to this:
def try_to_change(x):
x = 99
num = 42
try_to_change(num)
print(num) # Still 42
"See? The original didn't change. It must be pass by value."
The developers who say "pass by reference" will point to this:
def add_item(the_list):
the_list.append("new item")
my_list = ["original"]
add_item(my_list)
print(my_list) # ['original', 'new item']
"See? The original changed from inside the function. It must be pass by reference."
Both groups are looking at real behavior and drawing the wrong conclusion. Python is neither pass by value nor pass by reference. It is something different entirely.
What the Official Documentation Says
The definitive answer comes straight from the Python Tutorial, originally authored by Guido van Rossum, the creator of the language. In the section on defining functions, the tutorial explains that when a function is called, its arguments are placed into the function's local symbol table. It then clarifies that arguments use call by value, but the value in question is always an object reference rather than the object's data itself. The tutorial follows up by noting that "call by object reference" would be a more accurate description of this behavior (source: The Python Tutorial, Section 4.7, docs.python.org).
Read that carefully. Python passes arguments by value, but the "value" being passed is not the data itself. The value being passed is a reference to an object. This is a critical distinction. You are not getting a copy of the data (pure pass by value). You are not getting direct access to the caller's variable binding (pure pass by reference). You are getting a copy of the reference that points to the same object in memory.
This is why the Python community increasingly uses the term pass by assignment — because what happens inside the function is identical to what happens during any ordinary assignment statement.
The Academic Roots: Call by Sharing
This evaluation strategy was not invented for Python. It was first described by Barbara Liskov when she and her students at MIT designed the CLU programming language, with conceptual work beginning in 1973 and the first prototype implemented between 1974 and 1975. CLU introduced several foundational concepts still in wide use today, including abstract data types, iterators, multiple return values, and the evaluation strategy Liskov called call by sharing (source: CLU (programming language), Wikipedia; Liskov, Barbara, "A History of CLU," 1992).
The term "call by sharing" captures the key insight: when you pass an argument to a function, the function and the caller share access to the same object. But the function gets its own independent name (variable) for that object. The function can use the shared object, mutate it if it is mutable, but it cannot change what the caller's variable points to.
This idea has since been adopted by Python, Ruby, JavaScript, Java (for object references), and many other modern languages. However, as noted in various technical references, the term "call by sharing" is not in common use and the terminology remains inconsistent across sources, which is a major reason this topic generates so much confusion.
Names Are Not Boxes
To truly understand pass by object reference, you need to unlearn something that many introductory programming courses teach: that variables are containers that hold values.
In Python, variables are not boxes. Variables are names that refer to objects. This was the central thesis of Ned Batchelder's influential PyCon 2015 talk, "Facts and Myths about Python Names and Values." As Batchelder explained, Python's name-and-value behavior has an underlying simplicity that can be hard to see, especially coming from other languages (source: Ned Batchelder, PyCon 2015, nedbatchelder.com/text/names1.html). This principle is reinforced by the official Python documentation, which states in Section 9.2 on Scopes and Namespaces that assignments do not copy data — they simply bind names to objects (source: Python 3 Official Documentation, Section 9.2, docs.python.org).
Consider this sequence:
a = [1, 2, 3]
b = a
In a "variables are boxes" mental model, you might think b now contains a copy of the list. It does not. Both a and b are names that refer to the exact same list object in memory. You can verify this:
a = [1, 2, 3]
b = a
print(a is b) # True -- same object
print(id(a)) # e.g., 140234866534400
print(id(b)) # e.g., 140234866534400 (identical)
The id() function returns the identity of the object (in CPython, this is its memory address). They match because there is only one list. There are simply two names pointing at it. This is exactly the same thing that happens when you pass an argument to a function. The parameter name inside the function becomes a new name that refers to the same object.
Walking Through the Mechanics Step by Step
Let's trace what happens in a function call at the object-reference level.
Scenario 1: Mutating a Mutable Object
def add_element(items):
# 'items' is a new name referring to the same list object
items.append(99)
original = [1, 2, 3]
add_element(original)
print(original) # [1, 2, 3, 99]
Here is what happens at each stage:
originalis created as a name referring to a list object[1, 2, 3]somewhere in memory.- When
add_element(original)is called, Python evaluatesoriginalto get the object it refers to (the list). - That object reference is passed to the function and assigned to the local parameter name
items. Now bothoriginal(in the caller's scope) anditems(in the function's scope) refer to the same list object. items.append(99)mutates the list object in place. There is only one list, so the change is visible through any name that refers to it.- Back in the caller,
originalstill refers to that same (now modified) list.
Scenario 2: Reassigning the Parameter Name
def try_to_replace(items):
# This rebinds the local name 'items' to a completely new object
items = [99, 100, 101]
original = [1, 2, 3]
try_to_replace(original)
print(original) # [1, 2, 3] -- unchanged
- Same setup:
originalrefers to[1, 2, 3], anditemsis bound to the same object. items = [99, 100, 101]creates a brand new list object and rebinds the local nameitemsto it. This is just a local assignment. It has zero effect on whatoriginalrefers to.- Back in the caller,
originalstill points to the original list.
This is the critical difference from true pass by reference. In C++ pass by reference, reassigning the parameter would change the caller's variable. In Python, it never does, because the function only received a copy of the reference, not access to the caller's name binding.
Scenario 3: Immutable Objects and the Illusion of Pass by Value
def try_to_increment(n):
n = n + 1
counter = 10
try_to_increment(counter)
print(counter) # Still 10
Integers in Python are immutable. You cannot change the value of the integer object 10. The expression n + 1 creates an entirely new integer object 11 and rebinds the local name n to it. The caller's counter still refers to 10. This behavior looks like pass by value, but the mechanism is the same as every other case: the parameter is a new name bound to the same object. Immutability just prevents any in-place changes from showing through.
Scenario 4: The Walrus Operator and Name Binding
Python 3.8 introduced the walrus operator (:=), which performs assignment as part of an expression. This is another form of name binding, and it follows the exact same rules:
data = [1, 2, 3, 4, 5]
# The walrus operator binds 'n' to the return value of len()
if (n := len(data)) > 3:
print(f"List is long: {n} elements") # List is long: 5 elements
The := operator creates a name binding in the enclosing scope, not a copy. If you use it inside a comprehension, the variable n will exist in the enclosing scope after the comprehension finishes. This can surprise developers, but it follows directly from Python's consistent rule: all assignment is name binding, whether it uses =, :=, function parameters, for loop targets, import statements, or with ... as clauses.
Seeing It in Bytecode: The dis Module
One of the ways you can prove to yourself that Python's argument passing is just assignment is by examining the bytecode CPython generates. The dis module disassembles Python functions into their underlying virtual machine instructions, letting you see exactly what operations the interpreter performs.
import dis
def mutate(items):
items.append(99)
def rebind(items):
items = [99, 100, 101]
print("=== mutate ===")
dis.dis(mutate)
print("\n=== rebind ===")
dis.dis(rebind)
Running this produces output like:
=== mutate ===
2 RESUME 0
3 LOAD_FAST 0 (items)
LOAD_ATTR 18 (append)
LOAD_CONST 1 (99)
CALL 1
POP_TOP
RETURN_CONST 0 (None)
=== rebind ===
2 RESUME 0
3 BUILD_LIST 0
LOAD_CONST 1 ((99, 100, 101))
LIST_EXTEND 1
STORE_FAST 0 (items)
RETURN_CONST 0 (None)
Look at the critical difference. In mutate, LOAD_FAST retrieves the object that items refers to and then calls append on it. The object itself is being operated on. In rebind, BUILD_LIST creates a brand new list object, and STORE_FAST overwrites the local name items to point at the new object. The old object (the one the caller passed in) is not touched at all.
The STORE_FAST instruction is the bytecode equivalent of the = sign. Whenever you see it, a name is being rebound. Whenever you see LOAD_FAST followed by a method call, the object is being accessed through its existing name, which means mutation is possible. This distinction — STORE_FAST vs. method call on a LOAD_FAST — is the bytecode-level proof that rebinding and mutation are fundamentally different operations.
Common Traps That Follow From This
The Mutable Default Argument Trap
One of the notorious Python bugs flows directly from pass-by-object-reference semantics combined with how Python evaluates default argument values. Default values are evaluated once, at function definition time, not on each call. When that default is a mutable object, every call that uses the default shares the same object:
def append_to(element, target=[]):
target.append(element)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] <-- not a fresh list!
print(append_to(3)) # [1, 2, 3]
The list [] was created once when Python compiled the function definition. Every call that omits target gets that same list, now carrying all previous appends. You can actually inspect this object directly via append_to.__defaults__, which stores a tuple of the default values:
append_to(1)
append_to(2)
print(append_to.__defaults__) # ([1, 2],)
The standard fix is to use None as the sentinel and create the mutable default inside the function body:
def append_item(element, target=None):
if target is None:
target = []
target.append(element)
return target
This pattern is so common that PEP 671, authored by Chris Angelico and titled "Syntax for late-bound function argument defaults," proposes adding a => syntax to Python that would allow default expressions to be evaluated at call time rather than definition time. As of March 2026, PEP 671 retains Draft status and has not yet been accepted or rejected by the Python Steering Council. Python 3.14 was released in October 2025, and the next feature release, Python 3.15, is currently in alpha with a final release scheduled for October 2026 (source: PEP 790, peps.python.org).
Defensive Copying
When you pass a mutable object to a function and you do not want that function to modify your data, you need to make a copy explicitly:
import copy
def process_data(data):
working_copy = copy.deepcopy(data)
working_copy["processed"] = True
return working_copy
original_data = {"raw": [1, 2, 3], "processed": False}
result = process_data(original_data)
print(original_data["processed"]) # False -- safe
print(result["processed"]) # True
Python's copy module provides copy.copy() for shallow copies and copy.deepcopy() for recursive deep copies. Choosing between them depends on whether your data structure contains nested mutable objects. A shallow copy creates a new top-level container but shares the nested objects; a deep copy recursively duplicates everything. For large data structures, consider whether you can avoid the copy entirely by designing your function to return new data rather than mutating existing data.
Frozen Dataclasses: Structural Immutability
Since Python 3.7, the dataclasses module provides a frozen=True option that makes instances behave like immutable objects. This gives you a way to prevent mutation at the attribute level, which means passing a frozen dataclass to a function eliminates one entire category of bugs:
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str
port: int
debug: bool = False
config = Config(host="localhost", port=8080)
def use_config(cfg):
# cfg.debug = True # This would raise FrozenInstanceError
return f"Connecting to {cfg.host}:{cfg.port}"
print(use_config(config)) # Connecting to localhost:8080
With frozen=True, any attempt to set an attribute after construction raises FrozenInstanceError. The function still receives a reference to the same object (pass by object reference is still the mechanism), but because the object rejects mutation, the caller's data is structurally protected. This is a powerful pattern for configuration objects, value objects, and any data you want to guarantee remains unchanged after creation.
Using id() and is to Debug Reference Issues
When you are unsure whether two names refer to the same object, id() and the is operator are your diagnostic tools:
def investigate(param):
print(f"Inside function: id = {id(param)}")
param = "something new"
print(f"After reassignment: id = {id(param)}")
value = "hello"
print(f"Before call: id = {id(value)}")
investigate(value)
print(f"After call: id = {id(value)}")
Output:
Before call: id = 140234866227184
Inside function: id = 140234866227184
After reassignment: id = 140234866298416
After call: id = 140234866227184
Notice that the id inside the function before reassignment matches the caller's. After reassignment, the local name points to a different object. The caller's variable is untouched throughout. This three-step pattern — check id before, inside, and after — is the simplest way to prove to yourself what is really happening during any function call.
Closures and the Name-Binding Connection
Closures are functions that remember the environment in which they were created. They interact with Python's name-binding model in a way that catches many developers off guard, and they share a deep connection with pass-by-object-reference semantics.
def make_multiplier(factor):
def multiply(n):
return n * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
When make_multiplier(2) is called, the parameter factor is bound to the integer 2. The inner function multiply captures a reference to this name in its enclosing scope. It does not capture the value 2 directly — it captures the name binding. This distinction matters when the captured variable is mutable or when it gets rebound:
def make_counters():
counters = []
for i in range(3):
counters.append(lambda: i)
return counters
c0, c1, c2 = make_counters()
print(c0(), c1(), c2()) # 2 2 2 -- all return 2!
Every lambda captured a reference to the same name i, not a snapshot of its value at each iteration. By the time the lambdas are called, i has been rebound to 2. The fix uses a default argument to force early binding — which works precisely because default arguments are evaluated at definition time:
def make_counters_fixed():
counters = []
for i in range(3):
counters.append(lambda i=i: i) # default captures current value
return counters
c0, c1, c2 = make_counters_fixed()
print(c0(), c1(), c2()) # 0 1 2 -- correct!
This is one of the places where Python's name-binding model, its closure semantics, and its default-argument evaluation rules all intersect. If you understand pass by object reference, closures stop being mysterious.
How Other Languages Handle This
Understanding Python's approach is easier when you see how it compares to what other languages do. The differences are real, and they have practical consequences for how you write code when switching between languages.
| Language | Mechanism | Can function reassign caller's variable? | Can function mutate caller's object? |
|---|---|---|---|
| C (by value) | Copies the data into the function | No | No (copy is independent) |
| C (by pointer) | Copies a pointer to the data | Yes (via dereferencing) | Yes (via dereferencing) |
| C++ (by reference) | Alias to the caller's variable | Yes (directly) | Yes (directly) |
| Java (objects) | Copies the object reference | No | Yes |
| JavaScript | Copies the reference (objects) / value (primitives) | No | Yes (objects only) |
| Ruby | Call by sharing (same as Python) | No | Yes |
| Rust | Ownership & borrowing (move or borrow) | Only with &mut |
Only with &mut |
| Python | Call by sharing / pass by object reference | No | Yes (if mutable) |
The key observation is that Python, Java (for objects), JavaScript (for objects), and Ruby all use essentially the same mechanism: the function receives a copy of the reference, not a copy of the data and not an alias to the caller's binding. Python is unique among them in having a name for this mechanism that connects it to formal computer science through Liskov's original "call by sharing" terminology. However, in practice the behavior is identical across these languages: mutation is shared, reassignment is local.
Related PEPs and Language Evolution
Python's argument-passing semantics do not exist in isolation. Several Python Enhancement Proposals (PEPs) directly relate to how names, scopes, and types interact with the pass-by-assignment model.
PEP 3104 — Access to Names in Outer Scopes introduced the nonlocal statement in Python 3.0. Before this PEP, there was no way for a nested function to rebind a variable in an enclosing (but non-global) scope. With nonlocal, a nested function can explicitly rebind a name in its enclosing scope:
def outer():
count = 0
def increment():
nonlocal count
count += 1
increment()
increment()
print(count) # 2
outer()
Without nonlocal, the assignment count += 1 would create a new local variable, shadowing the outer one — another consequence of Python's name-binding rules. This is directly analogous to why reassigning a parameter name inside a function cannot affect the caller: without explicit mechanisms like nonlocal or global, assignment always targets the innermost scope.
PEP 3107 — Function Annotations (introduced in Python 3.0) and PEP 484 — Type Hints (introduced in Python 3.5) added the ability to annotate function parameters and return values with type information. While these do not change the passing mechanism at runtime (type hints are not enforced by the interpreter), they give developers a way to communicate expectations about what types of objects a function expects to receive. The PEP 484 text explicitly states that Python will always remain dynamically typed and type hints will never be mandatory (source: PEP 484, peps.python.org).
def process(items: list[int]) -> list[int]:
items.append(sum(items))
return items
The annotation items: list[int] tells readers and static analysis tools that items should be a list of integers, but it says nothing about whether the function will mutate the list. This is a documentation gap that pass-by-object-reference creates: callers cannot tell from the signature alone whether a function will modify their data.
PEP 649 — Deferred Evaluation of Annotations, which shipped with Python 3.14 in October 2025, changes how annotations are stored internally (they are now evaluated lazily rather than eagerly), but this change is about performance and forward-reference handling rather than altering any argument-passing behavior. It is worth noting because developers sometimes conflate "deferred evaluation" with "late binding" in the PEP 671 sense, but these are orthogonal concepts (source: What's New in Python 3.14, docs.python.org).
PEP 671 — Syntax for Late-Bound Function Argument Defaults proposes a => syntax for defaults that should be evaluated at each call rather than once at definition time. This would eliminate the mutable-default-argument trap at the language level, making the relationship between function definitions and object creation more explicit. As of March 2026, this PEP retains Draft status and has not yet been accepted or rejected by the Python Steering Council (source: PEP 671, peps.python.org).
Building the Right Mental Model
Here are the core principles, distilled:
Assignment is binding. When you write x = something, you are binding the name x to the object that something evaluates to. You are never copying data into a container. This is true for =, :=, for loop targets, with ... as, import, except ... as, and function parameters.
Function arguments are assigned. Calling f(my_var) is equivalent to executing param = my_var inside the function. The parameter name becomes a new reference to the same object.
Mutation and rebinding are different operations. Calling items.append(99) mutates the existing object. Writing items = [99] rebinds the local name to a new object. The first affects all references to that object. The second affects only the local name. At the bytecode level, mutation results in method calls on LOAD_FAST, while rebinding results in STORE_FAST.
Mutability determines visible effects. Since immutable objects cannot be changed in place, any "modification" creates a new object and rebinds the local name — making the function appear to have no side effects on the caller's data. Mutable objects can be changed in place, and those changes are visible everywhere that object is referenced.
Closures capture names, not values. A closure remembers the name binding in its enclosing scope, not the value that name had when the closure was created. This is the same name-binding model as function arguments, extended across scopes.
Ned Batchelder described this elegantly in his PyCon 2015 presentation: Python's name-and-value behavior has an underlying simplicity that becomes hard to discern when you arrive with assumptions from other languages. — Paraphrased from Batchelder's "Facts and Myths about Python Names and Values," PyCon 2015
Practical Guidelines for Writing Functions
Given everything above, here are concrete practices for working with Python's pass-by-object-reference semantics:
Return modified data explicitly. Rather than relying on mutation as a side effect, consider returning the modified object. This makes the data flow visible in the calling code:
def sorted_and_filtered(items):
return sorted(x for x in items if x > 0)
data = [3, -1, 4, -5, 9]
result = sorted_and_filtered(data)
# data is unchanged, result has the processed output
Document mutation in docstrings. If a function intentionally mutates its arguments (like list.sort() does), say so explicitly. Callers deserve to know. A convention used by many Python libraries is to return None from mutating methods (as list.sort() and list.reverse() do), signaling that the operation happened in place.
Use type hints to signal intent. While type hints do not prevent mutation, combining them with naming conventions and docstrings helps set expectations:
from typing import Sequence
def total(values: Sequence[float]) -> float:
"""Calculate the sum. Does not modify the input."""
return sum(values)
Using Sequence (a read-only abstract type) instead of list hints that the function treats its argument as read-only. For even stronger guarantees, consider accepting a tuple (immutable) or a frozen dataclass rather than a list (mutable).
Copy when in doubt. If you receive a mutable argument and want to work on it without affecting the caller, copy it at the top of the function. A shallow copy with list(items) or dict(data) is often sufficient, but use copy.deepcopy() for nested structures.
Use dis when in doubt. If you ever find yourself unsure whether a piece of code mutates or rebinds, run dis.dis(your_function) and look for STORE_FAST versus method calls. The bytecode never lies.
Prefer frozen dataclasses for data you do not want mutated. If your function accepts a configuration, a record, or any value object, using @dataclass(frozen=True) prevents mutation at the structural level, turning what would be a runtime debugging session into a clear FrozenInstanceError at the point of violation.
Python is not pass by reference. Python is not pass by value. Python passes object references by value, a mechanism with roots going back to Barbara Liskov's CLU language in the 1970s. Every function call is an assignment: the parameter name is bound to the same object the caller provided. Mutation of that object is shared; rebinding of the name is not. Understanding this single concept explains the mutable default argument trap, the behavior of closures and lambda captures, why the walrus operator scopes the way it does, the behavior of nested function scopes (and why nonlocal exists via PEP 3104), why id() and is behave the way they do, what STORE_FAST versus method-call bytecode actually means, and why defensive copying is sometimes necessary. The next time someone asks you whether Python is pass by reference, you will know the real answer — and more importantly, you will know why it matters in every function you write.