Parameter Passing¶
This page builds on Call-by-Object-Reference with practical guidance on how arguments flow from caller to function.
Positional and Keyword Arguments¶
A function can receive arguments by position, by name, or by a mix of both.
def describe(name, age):
print(name, age)
describe("Alice", 25) # positional
describe(name="Alice", age=25) # keyword
describe("Alice", age=25) # mixed: positional then keyword
describe(age=25, name="Alice") # keyword order doesn't matter
Positional arguments are matched left to right. Once you use a keyword argument, every argument after it must also be a keyword.
# SyntaxError: positional argument follows keyword argument
describe(name="Alice", 25)
Unpacking Arguments¶
The * and ** operators unpack sequences and mappings into positional and keyword arguments.
args = ("Alice", 25)
describe(*args) # same as describe("Alice", 25)
kwargs = {"age": 25, "name": "Alice"}
describe(**kwargs) # same as describe(age=25, name="Alice")
Passing Immutable Objects¶
When you pass an immutable object (int, str, tuple), the function cannot modify the original. Any operation that appears to change the value creates a new object and rebinds the local name.
def try_modify(text: str) -> str:
text = text.upper() # Creates a new string, rebinds the local variable
return text
original = "hello"
result = try_modify(original)
print(original) # hello
print(result) # HELLO
text.upper() creates a new string object. The assignment text = ... rebinds the local name text inside the function — it does not touch the caller's original.
The same pattern holds for integers, floats, and tuples — every "modification" is actually a creation of a new object followed by a rebinding.
def increment(n: int) -> int:
n = n + 1 # New int object; the caller's variable is untouched
return n
x = 5
y = increment(x)
print(x) # 5 — unchanged
print(y) # 6
Passing Mutable Objects¶
When you pass a mutable object (list, dict, set), the function can modify the original.
def add_item(collection: list, item: int) -> None:
collection.append(item) # Mutates the same object
my_list = [1, 2, 3]
add_item(my_list, 4)
print(my_list) # [1, 2, 3, 4]
collection and my_list point to the same list object. append() mutates that object in place.
Rebinding vs Mutating¶
The critical distinction is between rebinding a name and mutating an object.
# Initial state
my_list ──────► [1, 2, 3]
# After passing to function
my_list ──────► [1, 2, 3] ◄────── lst (parameter)
# After lst.append(4) — MUTATION: same object, caller sees the change
my_list ──────► [1, 2, 3, 4] ◄────── lst
# After lst = [100, 200] — REBINDING: lst points to a new object
my_list ──────► [1, 2, 3, 4]
lst ──────► [100, 200]
Rebinding a parameter never affects the caller. Mutating a mutable object always does.
def rebind(lst: list) -> None:
lst = [100, 200, 300] # Only rebinds the local name
print("inside:", lst)
def mutate(lst: list) -> None:
lst[0] = 100 # Modifies the same object
print("inside:", lst)
my_list = [1, 2, 3]
rebind(my_list)
print("after rebind:", my_list) # [1, 2, 3] — unchanged
mutate(my_list)
print("after mutate:", my_list) # [100, 2, 3] — changed
Output
inside: [100, 200, 300]
after rebind: [1, 2, 3]
inside: [100, 2, 3]
after mutate: [100, 2, 3]
Containers with Mixed Mutability¶
A tuple is immutable, but if it contains a mutable element, that inner element can still be mutated.
record = ("Alice", [90, 85, 92])
record[1].append(88) # Mutates the list inside the tuple
print(record) # ('Alice', [90, 85, 92, 88])
record[0] = "Bob" # TypeError: 'tuple' object does not support item assignment
The tuple itself cannot gain or lose elements, nor can its slots be reassigned. But the list at record[1] is a separate mutable object — the tuple merely holds a reference to it.
def add_score(student: tuple, score: int) -> None:
student[1].append(score) # Mutates the list inside the tuple
record = ("Alice", [90, 85])
add_score(record, 95)
print(record) # ('Alice', [90, 85, 95])
The identity of the inner list never changes:
record = ("Alice", [])
original_id = id(record[1])
for i in range(1000):
record[1].append(i)
print(id(record[1]) == original_id) # True — same list object throughout
Defensive Copying¶
When a function should not modify its input, work on a copy instead.
def calculate_stats(numbers: list) -> tuple:
sorted_nums = sorted(numbers) # Creates a new list; original untouched
return sorted_nums[0], sorted_nums[-1]
data = [3, 1, 4, 1, 5]
low, high = calculate_stats(data)
print(data) # [3, 1, 4, 1, 5] — original order preserved
print(low, high) # 1 5
For nested structures (lists of lists, dicts containing lists), a shallow copy is not enough — use copy.deepcopy from the standard library. That pattern is covered in the data structures chapter.
Type Hints Signal Intent¶
Return type annotations communicate whether a function mutates its argument or produces a new value.
def sort_in_place(items: list) -> None:
"""Mutates the caller's list."""
items.sort()
def sorted_copy(items: list) -> list:
"""Returns a new sorted list; original unchanged."""
return sorted(items)
-> None signals that the function works by side effect — it modifies the argument in place. A return type like -> list signals that the caller gets a new object back and the input is left alone.
Common Mistakes¶
Mistake 1: Expecting a function to modify an immutable argument¶
def increment(n: int) -> None:
n += 1 # Rebinds local n; caller's variable is unaffected
x = 5
increment(x)
print(x) # 5 — unchanged
# Fix: return the new value and reassign
def increment(n: int) -> int:
return n + 1
x = increment(x)
print(x) # 6
Mistake 2: Accidentally mutating a mutable argument¶
def calculate_stats(numbers: list) -> tuple:
numbers.sort() # Modifies the caller's list!
return numbers[0], numbers[-1]
data = [3, 1, 4, 1, 5]
low, high = calculate_stats(data)
print(data) # [1, 1, 3, 4, 5] — original order destroyed
# Fix: use sorted() which returns a new list
def calculate_stats(numbers: list) -> tuple:
s = sorted(numbers)
return s[0], s[-1]
Key Ideas¶
- Immutable arguments cannot be changed by a function — any assignment inside the function rebinds a local name and leaves the caller's variable untouched.
- Mutable arguments can be changed if the function mutates the object rather than rebinding the name.
- A tuple holding a mutable element (such as a list) allows mutation of that inner element even though the tuple itself is immutable.
- Use
-> Noneto signal in-place mutation and a return type to signal a new object is produced. - When in doubt, use
sorted(),.copy(), or similar non-mutating alternatives to protect the caller's data.
Next: Default Parameter Gotcha.