Skip to main content

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() and is
  • 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

TypeMutable?Can be changed inside function?
int, float, boolNoNo (arithmetic creates new objects)
strNoNo (string methods return new strings)
tupleNoNo (but mutable elements inside can change)
frozensetNoNo
listYesYes (via .append(), indexing, etc.)
dictYesYes (via key assignment, .update())
setYesYes (via .add(), .discard())
Custom objectsUsually YesYes (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

ConceptExampleEffect on caller
Mutation via methodlst.append(x)Visible (same object)
Mutation via indexlst[0] = 99Visible (same object)
Mutation via slicelst[:] = [1,2]Visible (in-place)
Rebindinglst = new_listNot visible (new object)
Immutable arithmeticn = n + 1Not visible (new int)
Shallow copylst.copy() / lst[:]Caller's list unchanged
Deep copycopy.deepcopy(lst)Caller's nested data unchanged
Check identityx is yTrue if same object
Check addressid(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:

  1. Accepts a configuration dict and a dict of updates
  2. Returns a new dict with the updates applied, WITHOUT modifying the original
  3. Handles nested dicts (deep merge, not shallow)
  4. Raises a TypeError if 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() or copy.deepcopy() when you need to work with a copy without affecting the caller
  • Use id() and is to inspect whether two names point to the same object
© 2026 EngineersOfAI. All rights reserved.