Parameters vs Arguments - Python's Pass-by-Object-Reference Model
Reading time: ~15 minutes | Level: Foundation → Engineering
Here is a question that trips up developers who switch to Python from Java or C++:
def append_item(lst, item):
lst.append(item)
def reassign(lst, new_list):
lst = new_list
my_list = [1, 2, 3]
append_item(my_list, 4)
print(my_list) # [1, 2, 3, 4] - changed!
reassign(my_list, [99, 100])
print(my_list) # [1, 2, 3, 4] - unchanged!
Why does append_item change the original list, but reassign does not?
If Python were pass-by-value, neither would change. If Python were pass-by-reference, both would change. But Python is neither - it uses a model called pass-by-object-reference, and understanding it is the key to writing Python functions that behave exactly as you intend.
What You Will Learn
- The precise semantic difference between a parameter and an argument
- Why "pass-by-value" and "pass-by-reference" are both wrong descriptions of Python
- What "pass-by-object-reference" actually means
- How to prove the model using
id()andis - The critical difference between rebinding a name and mutating an object
- How mutable vs immutable types interact with the argument passing model
- Why this matters for writing correct functions that work with lists, dicts, and custom objects
- How aliasing affects program behavior in subtle ways
Prerequisites
- Python variables and basic data types (int, str, list, dict)
- How to define and call functions (
def, return values) - Basic understanding that Python has mutable (list, dict) and immutable (int, str, tuple) types
Mental Model: Names Point to Objects
PYTHON'S OBJECT MODEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Variables/Parameters are LABELS (names)
Objects live on the HEAP
Assignment (=) makes a label point to an object
Function call passes the LABEL to the function
Before call:
┌─────────┐ ┌───────────────┐
│ x │────────►│ list object │
└─────────┘ │ [1, 2, 3] │
│ id: 0x1a2b │
└───────────────┘
During call f(x):
┌─────────┐ ┌───────────────┐
│ x │────────►│ list object │◄────────┐
└─────────┘ │ [1, 2, 3] │ │
│ id: 0x1a2b │ ┌────┴────┐
└───────────────┘ │ param │
└─────────┘
Both x and param point to the SAME object.
REBIND (param = new_list): MUTATE (param.append(4)):
┌─────────┐ ┌───────────────┐ ┌─────────┐ ┌────────────────┐
│ x │─►│ [1, 2, 3] │ │ x │─►│ [1, 2, 3, 4] │
└─────────┘ └───────────────┘ └─────────┘ └────────────────┘
┌─────────┐ ┌───────────────┐ ┌─────────┐ ▲
│ param │─►│ [99, 100] │ │ param │─────────┘
└─────────┘ └───────────────┘ └─────────┘
x unchanged. param points elsewhere. x AND param see the same change.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Watch: Python Functions and Arguments
Part 1 - Parameters vs Arguments: The Semantic Difference
These two words are often used interchangeably, but they mean different things:
Parameter - the name defined in the function signature. It is a variable name in the function's local scope.
def greet(name, greeting): # name and greeting are PARAMETERS
return f"{greeting}, {name}!"
Argument - the actual value passed when calling the function.
result = greet("Alice", "Hello") # "Alice" and "Hello" are ARGUMENTS
The mapping: argument "Alice" → parameter name. Argument "Hello" → parameter greeting.
This distinction matters when reading error messages:
TypeError: greet() takes 2 positional arguments but 3 were given
Python says "arguments" here - it is counting the values you passed at the call site.
Part 2 - What Python's Argument Passing Model Is NOT
Not Pass-by-Value (C-style)
In pass-by-value, the function receives a copy of the value. Modifying it inside the function has no effect on the caller.
// C: pass-by-value
void double_it(int x) {
x = x * 2; // modifies local copy only
}
int n = 5;
double_it(n);
printf("%d", n); // still 5
If Python were pass-by-value, this would print [1, 2, 3]:
def append_item(lst):
lst.append(99)
my_list = [1, 2, 3]
append_item(my_list)
print(my_list) # [1, 2, 3, 99] - changed! So Python is NOT pass-by-value
Not Pass-by-Reference (C++ reference style)
In pass-by-reference, the function receives a reference to the caller's variable itself. Reassigning the parameter changes the caller's variable.
// C++: pass-by-reference
void reassign(std::vector<int>& lst) {
lst = {99, 100}; // caller's variable is changed
}
If Python were pass-by-reference, this would print [99, 100]:
def reassign(lst):
lst = [99, 100] # rebinds the local name
my_list = [1, 2, 3]
reassign(my_list)
print(my_list) # [1, 2, 3] - unchanged! So Python is NOT pass-by-reference
Part 3 - What Python's Model Actually Is: Pass-by-Object-Reference
Python passes a reference to the object - not the value, not the variable. The parameter and the argument name both point to the same object in memory.
This means:
- Mutating the object (calling
.append(),.update(), modifying.attr) affects the caller's object - because it is the same object - Rebinding the parameter (
lst = something_else) does NOT affect the caller - it just makes the local name point to a different object
def demonstrate(lst):
print(f"Inside function, id(lst) = {id(lst)}")
lst.append(99) # MUTATION - affects caller's object
lst = [999] # REBINDING - local name only
print(f"After rebind, id(lst) = {id(lst)}")
my_list = [1, 2, 3]
print(f"Before call, id(my_list) = {id(my_list)}")
demonstrate(my_list)
print(f"After call, my_list = {my_list}")
Output:
Before call, id(my_list) = 140234567890
Inside function, id(lst) = 140234567890 ← same object!
After rebind, id(lst) = 140234599999 ← different object
After call, my_list = [1, 2, 3, 99] ← mutation survived, rebind did not
The id() function returns the memory address of the object. The parameter and the argument point to the same address - they are aliases of the same object.
Part 4 - Proving It with id() and is
def inspect_args(x, y):
"""Inspect the identity of arguments."""
print(f" x is the same object as a: {x is a}")
print(f" y is the same object as b: {y is b}")
print(f" id(x) == id(a): {id(x) == id(a)}")
a = [1, 2, 3]
b = {"key": "value"}
print(f"id(a) = {id(a)}")
print(f"id(b) = {id(b)}")
inspect_args(a, b)
Output:
id(a) = 140234567890
id(b) = 140234567920
x is the same object as a: True
y is the same object as b: True
id(x) == id(a): True
x is a returns True - they are the same object in memory, not copies.
Immutable types: the illusion of pass-by-value
def increment(n):
print(f" Before: id(n) = {id(n)}, n = {n}")
n = n + 1 # integers are immutable - creates a NEW object
print(f" After: id(n) = {id(n)}, n = {n}")
x = 10
print(f"Before: id(x) = {id(x)}, x = {x}")
increment(x)
print(f"After: id(x) = {id(x)}, x = {x}")
Output:
Before: id(x) = 140234000000, x = 10
Before: id(n) = 140234000000, n = 10 ← same object
After: id(n) = 140234000160, n = 11 ← new object (11 is a different object)
After: id(x) = 140234000000, x = 10 ← x still points to 10
n + 1 creates a new integer object 11. Assigning it to n rebinds the local name - x still points to 10. This is why integers behave as if they are passed by value - not because Python copies them, but because integers are immutable and arithmetic creates new objects.
Part 5 - Mutable vs Immutable: Practical Rules
| Type | Mutable? | Can be changed inside function? |
|---|---|---|
int, float, bool | No | No (arithmetic creates new objects) |
str | No | No (string methods return new strings) |
tuple | No | No (but mutable elements inside can change) |
frozenset | No | No |
list | Yes | Yes (via .append(), indexing, etc.) |
dict | Yes | Yes (via key assignment, .update()) |
set | Yes | Yes (via .add(), .discard()) |
| Custom objects | Usually Yes | Yes (via attribute assignment) |
# Immutable: str - behaves like pass-by-value
def shout(s):
s = s.upper() # creates a new string, rebinds local s
name = "alice"
shout(name)
print(name) # alice - unchanged
# Mutable: dict - mutation visible to caller
def add_key(d, key, value):
d[key] = value # mutates the dict in place
config = {"debug": False}
add_key(config, "verbose", True)
print(config) # {"debug": False, "verbose": True} - changed!
Part 6 - Aliasing and Its Consequences
When two names point to the same object, they are aliases. Changes through one are visible through the other.
# Aliasing in assignment
a = [1, 2, 3]
b = a # b is an alias for a - same object
b.append(4)
print(a) # [1, 2, 3, 4] - a changed because a and b are the same list
# To avoid aliasing, make a copy
c = a.copy() # c is a new list with the same elements
c.append(5)
print(a) # [1, 2, 3, 4] - a unchanged
print(c) # [1, 2, 3, 4, 5]
# Aliasing in function calls
def process(data):
data["result"] = "done" # mutates the caller's dict
config = {"mode": "train"}
process(config)
print(config) # {"mode": "train", "result": "done"} - modified!
# Defensive copy if you don't want mutation
def process_safe(data):
data = data.copy() # work on a copy
data["result"] = "done"
return data
config = {"mode": "train"}
new_config = process_safe(config)
print(config) # {"mode": "train"} - unchanged
print(new_config) # {"mode": "train", "result": "done"}
AI/ML Real-World Connection
Understanding pass-by-object-reference is critical when working with PyTorch and NumPy, where in-place operations can cause subtle bugs.
import torch
def normalize_inplace(tensor):
"""This MUTATES the caller's tensor."""
tensor -= tensor.mean()
tensor /= tensor.std()
# No return needed - caller's tensor is modified
def normalize_copy(tensor):
"""This returns a new tensor - caller's is unchanged."""
result = tensor.clone()
result -= result.mean()
result /= result.std()
return result
data = torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0])
original = data.clone()
normalize_inplace(data)
print(f"After inplace: data = {data}")
print(f"original still: {original}") # original unchanged (it's a clone)
data2 = torch.tensor([1.0, 2.0, 3.0, 4.0, 5.0])
normalized = normalize_copy(data2)
print(f"data2 unchanged: {data2}") # True - normalize_copy made a clone
print(f"normalized: {normalized}")
# NumPy: in-place vs copy distinction
import numpy as np
arr = np.array([1.0, 2.0, 3.0])
# In-place operations mutate the array passed in
def scale_inplace(a, factor):
a *= factor # mutates in place
scale_inplace(arr, 2.0)
print(arr) # [2. 4. 6.] - original changed
# The same pattern in a data preprocessing pipeline
def preprocess_batch(batch):
batch = batch.copy() # defensive: work on a copy
batch /= 255.0
return batch
# scikit-learn fit() modifies the model object in place
from sklearn.linear_model import LinearRegression
import numpy as np
model = LinearRegression()
def train(model, X, y):
model.fit(X, y) # modifies model.coef_, model.intercept_ in place
X = np.array([[1], [2], [3]])
y = np.array([2, 4, 6])
train(model, X, y)
print(model.coef_) # [2.] - model was modified in place
Common Mistakes
Mistake 1: Expecting immutable arguments to change
def add_one(n):
n = n + 1 # creates new int, rebinds local n - does NOT change caller's n
count = 0
add_one(count)
print(count) # 0, not 1 - this surprises beginners
# Fix: return the new value
def add_one(n):
return n + 1
count = 0
count = add_one(count)
print(count) # 1
Mistake 2: Unintentionally mutating a caller's data structure
def filter_positives(numbers):
# Bug: modifying the caller's list in-place
numbers[:] = [x for x in numbers if x > 0] # in-place slice assignment
return numbers
data = [1, -2, 3, -4, 5]
result = filter_positives(data)
print(data) # [1, 3, 5] - original mutated! Caller may not expect this
print(result) # [1, 3, 5]
# Fix: return a new list, don't mutate the input
def filter_positives(numbers):
return [x for x in numbers if x > 0]
data = [1, -2, 3, -4, 5]
result = filter_positives(data)
print(data) # [1, -2, 3, -4, 5] - unchanged
print(result) # [1, 3, 5]
Mistake 3: Confusing is with == when checking argument identity
def check(x, y):
print(x == y) # value equality
print(x is y) # identity (same object?)
a = [1, 2, 3]
b = [1, 2, 3] # same value, different object
check(a, b)
# True (== checks value)
# False (is checks identity - different objects)
check(a, a)
# True
# True (same object passed twice)
Interview Questions
Q1: What is the difference between a parameter and an argument?
Answer: A parameter is the variable name defined in the function signature (e.g., def f(x) - x is a parameter). An argument is the actual value passed when calling the function (e.g., f(42) - 42 is the argument). The argument is bound to the parameter when the function is called.
Q2: Is Python pass-by-value or pass-by-reference?
Answer: Neither. Python uses pass-by-object-reference (also called pass-by-assignment). When you call a function, the parameter receives a reference to the same object as the argument - not a copy. If the function mutates the object (e.g., lst.append(x)), the caller sees the change. If the function rebinds the parameter to a new object (lst = new_list), the caller's variable is unaffected.
Q3: What is the difference between rebinding and mutating in the context of function arguments?
Answer: Rebinding (param = new_value) makes the local parameter name point to a different object - it does not affect the caller's variable. Mutating (param.append(x), param["key"] = value) modifies the object that both the parameter and the caller's variable point to - the change is visible to the caller. The key: rebinding changes the label, mutating changes the object.
Q4: What does id() return, and how can you use it to understand argument passing?
Answer: id() returns a unique integer that represents the memory address of an object in CPython. You can confirm that a parameter and an argument refer to the same object by checking id(param) == id(arg) or param is arg inside the function. If they are equal, the same object is in memory - any mutation will be visible to the caller.
Q5: Why does modifying a list inside a function affect the caller, but modifying an integer does not?
Answer: Lists are mutable - operations like .append() modify the existing object in memory. Integers are immutable - n + 1 creates a brand new integer object. So n = n + 1 inside a function rebinds the local name n to the new object, leaving the caller's variable pointing to the original integer.
Q6: How do you write a function that receives a list and returns a modified version WITHOUT changing the original?
Answer: Make a copy before modifying:
def process(items):
items = items.copy() # shallow copy
items.append(99)
return items
Or use a list comprehension that builds a new list. For nested structures, use copy.deepcopy() instead of .copy().
Quick Reference Cheatsheet
| Concept | Example | Effect on caller |
|---|---|---|
| Mutation via method | lst.append(x) | Visible (same object) |
| Mutation via index | lst[0] = 99 | Visible (same object) |
| Mutation via slice | lst[:] = [1,2] | Visible (in-place) |
| Rebinding | lst = new_list | Not visible (new object) |
| Immutable arithmetic | n = n + 1 | Not visible (new int) |
| Shallow copy | lst.copy() / lst[:] | Caller's list unchanged |
| Deep copy | copy.deepcopy(lst) | Caller's nested data unchanged |
| Check identity | x is y | True if same object |
| Check address | id(x) == id(y) | True if same address |
Graded Practice Challenges
Level 1 - Predict the Output
def modify(data):
data["x"] = 100
data = {"y": 200}
d = {"x": 1}
modify(d)
print(d)
Show Answer
Output: {"x": 100}
data["x"] = 100 mutates the dict that d and data both point to - so d["x"] becomes 100. Then data = {"y": 200} rebinds the local data to a new dict - d still points to the original dict (now {"x": 100}).
Level 1 - True or False
True or false: In Python, passing a string to a function and modifying it inside the function will change the original string.
Show Answer
False.
Strings are immutable. Any string operation (.upper(), concatenation, slicing) returns a new string object - it does not modify the original. Assigning the result to the parameter just rebinds the local name, leaving the caller's string unchanged.
Level 2 - Debug the Code
Find and fix the bug:
def remove_negatives(numbers):
for n in numbers:
if n < 0:
numbers.remove(n)
return numbers
data = [-1, 2, -3, 4, -5]
result = remove_negatives(data)
print(result)
Show Answer
Bug: You cannot reliably remove items from a list while iterating over it. Python's iterator advances by index, and removing an element shifts subsequent elements, causing some to be skipped. For [-1, 2, -3, 4, -5], the output is [2, -3, 4] - -3 is missed because the iterator skipped over it after removing -1.
Fix 1: Build a new list (preferred - does not mutate the input):
def remove_negatives(numbers):
return [n for n in numbers if n >= 0]
Fix 2: Iterate over a copy (mutates the original, but correctly):
def remove_negatives(numbers):
for n in numbers[:]: # iterate over a copy
if n < 0:
numbers.remove(n)
return numbers
Level 3 - Design Challenge
Design a safe_update function that:
- Accepts a configuration dict and a dict of updates
- Returns a new dict with the updates applied, WITHOUT modifying the original
- Handles nested dicts (deep merge, not shallow)
- Raises a
TypeErrorif either argument is not a dict
Demonstrate with a config that has nested settings.
Show Reference Solution
import copy
def safe_update(config, updates):
"""Return a new dict with updates merged into config. No mutation."""
if not isinstance(config, dict):
raise TypeError(f"config must be a dict, got {type(config).__name__}")
if not isinstance(updates, dict):
raise TypeError(f"updates must be a dict, got {type(updates).__name__}")
# Deep copy to avoid aliasing issues
result = copy.deepcopy(config)
for key, value in updates.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = safe_update(result[key], value) # recursive deep merge
else:
result[key] = copy.deepcopy(value)
return result
# Demonstration
base_config = {
"model": {
"type": "transformer",
"layers": 12,
"hidden_size": 768,
},
"training": {
"lr": 1e-4,
"epochs": 10,
},
"debug": False,
}
overrides = {
"model": {
"layers": 24, # override just this key
"dropout": 0.1, # add a new key
},
"debug": True,
}
updated = safe_update(base_config, overrides)
print("Original:", base_config)
# Original unchanged - safe_update never mutated it
print("Updated:", updated)
# model.layers = 24, model.hidden_size = 768 (preserved), model.dropout = 0.1 (added)
# debug = True
Key Takeaways
- A parameter is the name in the function definition; an argument is the value passed at the call site
- Python uses pass-by-object-reference: the parameter receives a reference to the same object as the argument
- Python is not pass-by-value (no copy is made) and not pass-by-reference (rebinding doesn't affect the caller)
- Mutating an object inside a function (
.append(), dict assignment) changes the caller's object - they share the same reference - Rebinding a parameter (
param = new_value) only changes the local name - the caller's variable is unaffected - Immutable types (int, str, tuple) behave like pass-by-value because any "modification" creates a new object
- Mutable types (list, dict, set, custom objects) can be changed inside a function and the caller will see the change
- Use
.copy()orcopy.deepcopy()when you need to work with a copy without affecting the caller - Use
id()andisto inspect whether two names point to the same object
