Python doesn't pass arguments by reference, and it doesn't pass them by value. It uses call by sharing, a model named by Barbara Liskov in the CLU language in 1974. The function receives a new local name that refers to the same object the caller had; whether the caller sees changes depends on what you do inside the function, not on the type of the argument. Mutate the object in place and the caller sees it. Rebind the local name to something else and the caller does not. The "mutable default argument" trap, the surprises with *args and **kwargs, and the "I want to return a new value through a parameter" question all reduce to that single rule. This piece is one of 17 short explainers in our Python Concepts Explained reference.
Key Takeaways
Python is neither pass-by-reference nor pass-by-value. It's call by sharing (Liskov, 1974). The function parameter is a new local name for the same object the caller passed.
Mutate the object in place (list.append, dict[k]=v, attribute assignment), and the caller sees the change because there is only one object.
Rebind the parameter (x = something_new), and only the local name changes. The caller's name still points to the original object.
The mutable default argument trap.def f(x=[]) creates the default list once at function definition, then reuses it on every call. Use x=None with an inside-the-body default instead.
*args and **kwargs follow the same rule. The tuple and dict that wrap them are new, but their elements are references to the caller's objects. Mutating an element is visible outside; rebinding the wrapper is not.
Two names labeling one object, and four things the function can do with them. The rule never changes; the type of the argument doesn't change it either.
Python Doesn't Pass By Reference OR Value
The terminology comes from C, where the distinction is concrete. Pass by value copies the argument's value into a new memory slot for the function; the function gets its own copy. Pass by reference passes the address of the caller's variable; the function can write to that address and the caller sees it. Python does neither, but its behavior has been mistaken for both, which is the source of the confusion.
def looks_like_value(x):
x = x + 1 # rebinding; caller's a unchanged
def looks_like_reference(x):
x.append(99) # mutating; caller's a changed
a = 5
looks_like_value(a)
print(a) # 5 looks "by value"
a = [1, 2, 3]
looks_like_reference(a)
print(a) # [1, 2, 3, 99] looks "by reference"
Same Python, same call mechanic. The difference is what the function does inside, plus the fact that 5 is immutable while [1, 2, 3] is not. The type of the argument never changes how Python passes it; it changes only what operations are available on the object once the function has it.
Call by Sharing: Liskov's Term
The technically correct name for Python's argument mechanic is call by sharing, coined by Barbara Liskov in the design of the CLU programming language in 1974 (see Wikipedia: Evaluation strategy). The model also appears in Java (for object types), Ruby, and JavaScript (for objects and arrays). The "sharing" refers to caller and callee sharing access to the same object, not to a shared resource in the threading sense.
What the name means
The function does not get the caller's variable. The function gets a new local variable that refers to the same object. Operations that mutate the object are observable through both names. Operations that rebind one of the names (the local one, since that's what the function controls) are not observable through the other. There is no special "reference" passing happening; the parameter is bound to the same object the argument was bound to, full stop. The Python tutorial covers this under Defining Functions in slightly less formal language.
The Four Patterns Inside a Function
Once you internalize call by sharing, every "did this function change my variable?" question reduces to identifying which of four patterns the function used.
Pattern 1: Mutate the object in place
def add_99(items):
items.append(99) # mutate the shared object
a = [1, 2, 3]
add_99(a)
print(a) # [1, 2, 3, 99] ← caller sees the change
One object. Two names. The mutation is observable through both. The same applies to dict[k] = v, set.add, attribute assignment on a class instance, and anything else that changes an object's state without rebinding.
Pattern 2: Rebind the local name
def replace(items):
items = [9, 9, 9] # rebinds local; the original list is untouched
a = [1, 2, 3]
replace(a)
print(a) # [1, 2, 3] ← caller unchanged
The function changed which object the name items labels inside the function. The outer name a still labels the original list. There is no mechanism by which assignment to a local name could reach back into the caller's scope.
Pattern 3: Build a new object, then rebind
def add_99(items):
items = items + [99] # creates NEW list, rebinds local
a = [1, 2, 3]
add_99(a)
print(a) # [1, 2, 3] ← caller still unchanged
This is the pattern that most surprises beginners. items + [99] creates a new list (concatenation never mutates either operand); the assignment then rebinds the local name to that new list. From the caller's perspective, nothing visible happened. The original list is still there, intact, labeled by a.
Pattern 4: Return the new value
def add_99(items):
return items + [99]
a = [1, 2, 3]
a = add_99(a)
print(a) # [1, 2, 3, 99]
The function builds a new value and returns it. The caller is explicit about reassigning. This is the idiom most production code uses for "transform a value" operations, because the data flow is visible at the call site (the caller can see a = add_99(a)). It also works for immutable types where in-place mutation is impossible.
The id() Diagnostic
When in doubt, id() tells you which object a name refers to. It returns a unique integer for each live object. Same id, same object; different id, different object.
def inspect(x):
print("inside, before:", id(x))
x.append(99)
print("inside, after mutate:", id(x))
x = [9, 9, 9]
print("inside, after rebind:", id(x))
a = [1, 2, 3]
print("outside, before:", id(a))
inspect(a)
print("outside, after:", id(a), a)
You'll see the outside id matches the "inside, before" and "inside, after mutate" ids (same object), but "inside, after rebind" gets a different id (new object). The outside id and value never change because the outside name is never reassigned. This is the simplest reliable way to convince yourself that call by sharing matches what's actually happening.
The Mutable Default Argument Trap
Python evaluates default argument values once, at function definition time. The result is stored on the function object and reused on every call. For immutable defaults (numbers, strings, None) this is invisible; for mutable defaults it produces one of the most-asked Python questions of all time.
def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ← the SAME default list, mutated again
print(append_to(3)) # [1, 2, 3]
The target=[] default is the same list object across all three calls. The first call mutates it; the second call sees the result; the third call extends it further. This is rarely what anyone wants. The standard fix uses None as a sentinel and constructs a fresh list inside the body:
def append_to(item, target=None):
if target is None:
target = []
target.append(item)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [2] ← fresh list every call
The same pattern applies to dicts, sets, and any other mutable default. The rule of thumb: default arguments should be immutable. If you need a mutable default, use None and create the real default inside the function.
Rule: never write def f(x=[]) or def f(x={}). Use x=None and construct the default inside the function body. Static analysis tools like ruff and pylint flag this pattern by default.
How *args and **kwargs Pass References
Variable-argument functions follow the same call-by-sharing rule, with one subtle wrapper. When the caller does f(a, b, c) with *args, Python builds a new tuple holding references to a, b, and c. The tuple is fresh; its contents are not.
def mutate_first(*args):
args[0].append(99) # mutate the first object
shared = [1, 2, 3]
mutate_first(shared)
print(shared) # [1, 2, 3, 99] ← shared list changed
The args tuple is internal to the function. The list inside it is the caller's. The same logic applies to **kwargs: a new dict wraps the keyword arguments, but the values are shared references.
The wrapper is new; the elements are shared. If you need a true defensive copy, use the copy module on the args or kwargs values (see our shallow vs deep copy explainer).
Returning a Tuple: The "Pass Back" Pattern
If you want a function to "modify" an immutable argument (like an integer) and have the caller see it, the function must return the new value. For multiple values, return a tuple, which Python unpacks naturally at the call site.
def normalize(x, y):
length = (x*x + y*y) ** 0.5
return x / length, y / length
a, b = normalize(3, 4)
print(a, b) # 0.6 0.8
For functions that produce many related values, prefer collections.namedtuple or a dataclass over a bare tuple; the names at the call site make the code much more readable. This pattern replaces "modify the caller's variable" in well-designed Python code. The data flow stays explicit; testing stays straightforward.
Connection to Slicing and Copy
The same "one object, two names" model that explains call by sharing also explains the shallow-copy behavior covered in our shallow vs deep copy and slicing explainers. b = a[:] makes a new outer list and shares inner objects; def f(x): ... binds x to the same object as the caller's argument. Both produce the same trap when the inner objects are mutable. Once you internalize the "label, not box" model, the rules for assignment, function calls, slicing, and copy all fall out of it. There is no fifth concept to learn (see Wikipedia: Variable (computer science) for the broader CS framing).
Common Mistakes
Five traps to watch for:Mistake 1: "the function changed my list, that's a bug". It's by design: the function got the same object you had. If you don't want the function to mutate it, pass a copy: f(my_list.copy()), or write the function to be non-mutating.Mistake 2: "I rebound x inside the function; why didn't outer change?" Rebinding is local. There is no mechanism to assign back into the caller's scope; only mutating the shared object reaches the caller.Mistake 3: "I want to return new values via output parameters". Python doesn't have output parameters. Return the values from the function instead; unpack at the call site with a, b = f(...).Mistake 4: mutable default arguments. Use None sentinels with inside-the-body defaults. Tools like ruff warn about this on every modern Python project.Mistake 5: assuming *args isolates the caller. The args tuple is new, but its elements are references to the caller's objects. If you need full isolation, copy.deepcopy the elements before mutating.
Frequently Asked Questions
Does Python pass arguments by reference or by value?
Neither. Python uses call by sharing, a model coined by Barbara Liskov in 1974 for the CLU language. The function receives a new local name that refers to the same object the caller had. Mutate the object in place, the caller sees the change. Rebind the local name, the caller does not. The behavior depends on what you do inside the function, not on the type of the argument.
Why doesn't my function change my integer or string?
Because integers and strings are immutable: you can't change them in place, only rebind the local name to a new object. The rebinding only affects the function's local scope. If you want the caller to see the new value, return it and have the caller reassign: a = update(a). The mechanism is the same for lists and dicts; the difference is that you CAN mutate those in place, which makes the change visible without a return.
What is the mutable default argument trap in Python?
Default argument values are evaluated once, at function definition time, not on each call. So def append_to(x, target=[]) creates ONE list at definition and reuses it across every call that omits target. After the first call mutates it, every subsequent call sees the modified list. The standard fix is to use None as the sentinel and create a fresh container inside the function: def append_to(x, target=None): target = [] if target is None else target.
How do I make a Python function not change my list?
Three options. First, pass a shallow copy: f(my_list.copy()) or f(my_list[:]). The function gets its own outer list; mutations inside don't reach the original. Second, pass a deep copy with copy.deepcopy(my_list) if the list contains nested mutables. Third, write the function defensively: have it not mutate its arguments and return a new value instead. The third option is the cleanest design.
Are *args and **kwargs passed by reference in Python?
They follow the same call-by-sharing rule as named arguments. *args is a tuple of references to the same objects the caller passed. **kwargs is a dict of references. Mutating an element of args or kwargs is visible to the caller if the element itself is mutable. Rebinding args or kwargs to a new tuple or dict inside the function does not affect the caller. The wrapping is new (a fresh tuple and dict are built); the elements are shared.
The Bottom Line: Two Names, One Object, One Rule
The function parameter is a new local label for the same object the caller passed. Mutate that object in place and the caller sees the change because there's only one object to see. Rebind the local label to something else and the caller is untouched because the local label is local. That single rule, plus the convention of returning new values rather than mutating inputs, covers every real situation you'll meet in production Python code. The "is Python pass-by-reference?" question becomes a non-question once you let go of the dichotomy and adopt Liskov's vocabulary. For the rest of the most-asked Python concept questions, browse the full Python Concepts Explained index.
Make Call-By-Sharing Reflexive on Real Drills
CodeGym's Python track turns the call-by-sharing mental model into muscle memory through 800+ hands-on tasks across 62 levels. The AI validator checks every submission in seconds; the AI mentor explains what broke when you get stuck. First level free; full plan on the pricing page.
Begin your Python learning path →
GO TO FULL VERSION