Pass by reference or value?
Neither — it's call by sharing.
The most-asked Python concept question. The answer is that Python is neither pass-by-reference nor pass-by-value — it's call by sharing. When you pass an argument, the function receives a reference to the same object. It can mutate the object (if mutable), but it can't rebind the caller's variable. This explains both the "modifying a list changes the original" behavior and the "reassigning a parameter doesn't" behavior. This page covers the model, the four cases, and the mutable default argument trap that follows from it.
Short answer: Call by sharing — the function gets a reference to the same object, not a copy and not the caller's variable. Mutating a mutable argument (list, dict, set) affects the caller. Reassigning the parameter does not. Ints and strings look like pass-by-value because they're immutable — mutation is impossible, only rebinding. The mutable default argument trap (def fn(items=[])) is a direct consequence: the default is shared across calls. Fix: def fn(items=None): if items is None: items = [].
✅ The four cases
Mutable vs immutable, mutate vs rebind.
# Case 1: mutable argument, mutate inside — caller sees change
def append_to(items):
items.append(4)
my_list = [1, 2, 3]
append_to(my_list)
print(my_list) # [1, 2, 3, 4] — modified
# Case 2: mutable argument, rebind inside — caller doesn't see change
def rebind(items):
items = [99, 99] # rebind local name, not the caller's
items.append(4)
my_list = [1, 2, 3]
rebind(my_list)
print(my_list) # [1, 2, 3] — unchanged
# Case 3: immutable argument, rebind inside — caller doesn't see change
def increment(n):
n = n + 1
return n
x = 5
increment(x)
print(x) # 5 — unchanged
# x = increment(x) would update it
# Case 4: immutable argument, "mutate" is impossible
def upper(s):
s.upper() # creates a new string, discarded
return s.upper()
name = 'alice'
upper(name)
print(name) # 'alice' — unchanged
# The summary:
# ┌─────────────────┬──────────┬──────────┐
# │ │ mutate │ rebind │
# ├─────────────────┼──────────┼──────────┤
# │ mutable arg │ ✅ caller │ ❌ caller │
# │ immutable arg │ n/a │ ❌ caller │
# └─────────────────┴──────────┴──────────┘
# Mutating affects the caller.
# Rebinding never does.
# That's call by sharing.
The mental model:
When you call f(x), Python passes a reference
to the object x is bound to. The function's
parameter is a new name bound to the same object.
Inside the function:
param → same_object
The caller's x also → same_object.
Two names, one object. Like two people
pointing at the same house.
Mutations:
If the object is mutable (list, dict, set):
param.append(4) — modifies the object
The caller's x sees the change, because
x is still pointing at the same (now modified)
object.
If the object is immutable (int, str, tuple):
Cannot mutate. Any "mutation" is really a
rebind (creating a new object). The caller's
x is unaffected.
Rebinding:
param = something_else
This rebinds the local name param to a new
object. The caller's x is still pointing at
the original object.
param → new_object
x → original_object
Now two names point to two objects. Like
one person pointing at a new house; the
other still points at the old one.
Why this is called "call by sharing":
The object is shared between the caller and
the function. The reference is passed (shared),
not the object (copied) or the variable (as in
true pass-by-reference).
Also called "pass by object reference" or
"pass by object sharing."
It's the same model as Java, JavaScript, Ruby,
C#, and many others. It's the standard
for object-oriented languages.
It's different from true pass-by-reference
(C++ &, C# ref) where you can rebind the
caller's variable. And different from
pass-by-value (C) where a copy is made.
In call-by-sharing, you can mutate the object,
but you can't rebind the caller's variable.
The misconception:
"Python is pass by reference" — no, you can't
rebind the caller's variable.
"Python is pass by value" — no, mutations
are visible to the caller.
"Python is pass by object reference" — yes,
but the name is rarely used; "call by sharing"
is the standard term.
The practical rule:
If you want to modify the caller's object:
mutate it (list.append, dict.update, etc.)
If you want to leave the caller's object alone:
make a copy first, or build a new object
and return it.
If you want to update the caller's variable:
return the new value and let the caller
reassign: x = f(x).
Three patterns for "modify a list":
✅ Pattern 1 — mutate in place (caller sees change)
def append(item_list, value):
item_list.append(value)
✅ Pattern 2 — copy, modify, return (caller unchanged)
def with_appended(item_list, value):
return item_list + [value]
✅ Pattern 3 — copy, modify, return (explicit copy)
import copy
def with_appended(item_list, value):
result = copy.copy(item_list)
result.append(value)
return result
Choose based on:
Pattern 1: efficient for large lists,
explicit mutation, good for builders
Pattern 2: functional, no side effects,
idiomatic for pipelines
Pattern 3: explicit, works for any mutable type
Modern Python tends toward pattern 2 for
readability. But pattern 1 is fine for
in-place operations (logging builders,
accumulators). The key is documentation —
the caller should know which pattern the
function uses.
✅ The mutable default trap
The famous gotcha, explained by call by sharing.
# ❌ The famous bug
def bad(items=[]):
items.append('x')
return items
print(bad()) # ['x']
print(bad()) # ['x', 'x']
print(bad()) # ['x', 'x', 'x']
# Why: the default [] is created ONCE at function
# definition time. Every call uses the same list.
# ✅ Fix 1 — None + create inside
def good1(items=None):
if items is None:
items = []
items.append('x')
return items
print(good1()) # ['x']
print(good1()) # ['x'] — new list each call
# ✅ Fix 2 — immutable default (tuple)
def good2(items=()):
items = list(items) # convert to list
items.append('x')
return items
print(good2()) # ['x']
print(good2()) # ['x']
# ✅ Fix 3 — required argument
def good3(items):
items.append('x')
return items
print(good3([])) # ['x']
print(good3([])) # ['x'] — new list each call
# Same trap with dicts:
def bad_dict(config={}):
config['debug'] = True
return config
print(bad_dict()) # {'debug': True}
print(bad_dict()) # {'debug': True} — but same dict!
# ✅ Fix
def good_dict(config=None):
if config is None:
config = {}
config['debug'] = True
return config
# Same trap with sets:
def bad_set(items=set()):
items.add('x')
return items
# ✅ Fix
def good_set(items=None):
if items is None:
items = set()
items.add('x')
return items
Why the trap exists:
def f(items=[]):
...
The [] is evaluated ONCE — when the def
statement runs. Not when the function is called.
At definition time:
f's __defaults__ = ([],) — a tuple with one list
The list is bound to the default argument.
Every call to f() with no arguments uses that
same list. f() mutates it. f() again uses the
same (mutated) list.
The fix:
✅ def f(items=None):
if items is None:
items = []
...
None is the sentinel. On every call:
- items is None (no argument passed) → create fresh []
- items is not None (argument passed) → use it
The default is None, which is immutable and
shared safely. The mutable container is created
fresh each time.
Why None and not () or 0 or ''?
Use None because:
→ It's the conventional "no value" sentinel
→ It's unambiguous — no one passes None
meaning "empty list"
→ It's immutable and shared safely
Using () (empty tuple):
def f(items=()):
items = list(items) # convert
...
This works too. But the `list(items)` call on
every invocation has a small cost. None +
`if is None` avoids that cost when the caller
passes a list.
Use () when you want "the default is empty"
to be a natural reading. Use None when you
want the default to be created only when needed.
For dicts:
✅ def f(config=None):
if config is None:
config = {}
...
Same pattern, same reasoning.
For sets:
✅ def f(items=None):
if items is None:
items = set()
...
Why this is the "famous bug":
→ It's in every Python tutorial
→ It's in every Python style guide
→ It's still in every codebase that
wasn't reviewed
→ It's caught by linters (pylint W0102,
flake8-bugbear B006)
→ It's easy to fix (once you know)
→ It's confusing when you first see it
If you have a Python codebase with more
than 1000 lines, grep for `=[]`, `={}`, `=set()`.
You'll find at least one. Fix them all.
What about immutable defaults?
✅ def f(x=0): — fine
✅ def f(s=''): — fine
✅ def f(t=()): — fine (immutable)
✅ def f(b=True): — fine
✅ def f(n=None): — fine
These are safe because they can't be mutated.
Rebinding them inside the function doesn't
affect the caller. The shared default is safe.
Only mutable defaults are dangerous.
The trap is specific to lists, dicts, sets,
and custom mutable objects.
For a class field default:
This is a different trap:
❌ class Bag:
items = [] # shared across all instances
The class-level [] is created once, at class
definition. Every instance shares it.
✅ class Bag:
def __init__(self):
self.items = [] # new list per instance
Create mutable state in __init__, not as
class attributes.
Same principle: anything evaluated once
(class body, function defaults) is shared.
Anything evaluated per call/instance (function
body, __init__) is fresh.
The pattern to know:
Mutable default? → use None + is None check
Class attribute? → set in __init__
Global state? → avoid, or use a function
If you're ever unsure: initialize inside,
not as a default or class attribute.
The cost is one line. The benefit is no
surprise sharing.
✅ The three patterns for mutating arguments
Choose one, document it.
# Pattern 1: Mutate in place — caller sees the change
def add_user(users, name):
"""Add a user to the list. Modifies users in place."""
users.append(name)
users = ['Alice']
add_user(users, 'Bob')
print(users) # ['Alice', 'Bob']
# Caller knows: users is modified
# Pattern 2: Copy and return — caller's list unchanged
def with_user(users, name):
"""Return a new list with the user added. Does not modify input."""
return users + [name]
users = ['Alice']
new_users = with_user(users, 'Bob')
print(users) # ['Alice'] — unchanged
print(new_users) # ['Alice', 'Bob']
# Pattern 3: Copy explicitly, modify, return
def add_user_safe(users, name):
"""Return a copy with the user added. Does not modify input."""
result = list(users) # shallow copy
result.append(name)
return result
# Which pattern to use:
# ✅ Pattern 1 — for builder-like operations:
# - Adding to a log
# - Accumulating results
# - Sorting a list in place
# - Where the caller expects mutation
# Document the mutation in the docstring.
# ✅ Pattern 2 — for functional-style code:
# - Data transformations
# - Anything returned from a function that
# shouldn't modify the input
# - Pipelines, comprehensions, chained calls
# ✅ Pattern 3 — when you need to copy AND modify:
# - Long lists where copying is expensive
# - Non-trivial transformations
# - Explicit is better than implicit
# ❌ What to avoid:
def bad_pattern(items):
# Mutates AND returns — ambiguous
items.append('x')
return items
# Is the caller supposed to use the return value?
# Or rely on the side effect?
# Either behavior is possible, and the wrong
# assumption causes bugs.
# ✅ Pick one:
def mutate_only(items):
items.append('x')
# no return (returns None)
def return_only(items):
return items + ['x']
# no mutation
# For strings (immutable), only the return pattern works:
def add_prefix(text, prefix):
return prefix + text
# Can't mutate a string — always returns new
# For dicts, the same three patterns:
def set_flag_inplace(config, flag):
config['debug'] = flag
def with_flag(config, flag):
return {**config, 'debug': flag}
# For custom objects, mutations are natural:
class Config:
def __init__(self):
self.debug = False
def enable_debug(config):
config.debug = True # mutation visible to caller
# The rules of thumb:
# 1. In-place mutation for accumulators and builders.
# 2. Return-new for pure functions and transformations.
# 3. Never both — pick one and document.
# 4. For mutable default arguments, use None + check.
# 5. For class attributes, set in __init__.
Pattern 1: mutate in place
def add_user(users, name):
users.append(name)
✅ Efficient for large lists
✅ Natural for accumulators
✅ Clear from the docstring
Used in:
- Logging (log.append(event))
- Builders (items.append(x))
- Sort (lst.sort())
- Accumulators (total += x)
The caller knows: the argument is modified.
Pattern 2: copy and return
def with_user(users, name):
return users + [name]
✅ No side effects
✅ Easy to test
✅ Composes well
✅ Thread-safe (no shared mutation)
Used in:
- Data transformations (map, filter)
- Pipelines (result = step1(x) → step2(result))
- Immutable data structures
- Functional programming
The caller knows: the argument is unchanged.
Pattern 3: copy explicitly, modify, return
def add_user_safe(users, name):
result = list(users)
result.append(name)
return result
✅ Explicit (list(users) shows intent)
✅ Works for any mutable type
✅ Can optimize for specific types
Used in:
- Complex transformations
- When you want to be explicit
- When you need to preserve the original
but modify the copy
The caller knows: original is unchanged,
return value is the copy.
The anti-pattern: mutate AND return
def bad(items):
items.append('x')
return items
❌ Is the caller supposed to use the return?
❌ Or rely on the mutation?
❌ Both work, so both bugs are possible
❌ Ambiguous API
The fix: pick one.
- Mutate, return None (document)
- Don't mutate, return new (document)
Choosing a pattern for your API:
For a "log" or "accumulator":
Mutate in place.
The caller expects the log to grow.
For a "process data" function:
Return new, don't mutate.
The caller expects the input unchanged.
For a "builder" pattern:
Mutate in place, return self for chaining.
builder.add('a').add('b').build()
For a "transform" pattern:
Return new. Always.
transformer(data).filter().map().result()
The one rule:
The docstring must match the behavior.
Either:
"Modifies the input list in place."
or
"Returns a new list. Does not modify input."
If the docstring says one thing and the code
does another, that's the bug.
For functions that need both:
Some APIs support both:
def process(items, copy=False):
if copy:
items = list(items)
items.append('x')
return items
But this is confusing. The caller has to
know about the `copy` parameter and set it
correctly.
The modern Python style: don't do this.
Two functions:
process_inplace(items) — mutates
with_processed(items) — returns new
Or one function with a clear default:
def process(items):
return items + ['x'] # always new
Callers who want in-place:
items[:] = process(items)
This makes the mutation explicit at the
call site, not hidden behind a parameter.
The mental model:
In-place: "this function modifies X"
Caller knows, docstring says, tests check.
Return-new: "this function gives you a new value"
Caller knows, docstring says, tests check.
Both: "I don't know what this function does"
The bug is built into the API.
Pick one. Document it. Test it.
Never both.
Don't use a mutable default argument. def f(items=[]) is the most famous Python bug. The [] is evaluated once, at function definition, and shared across every call. The fix is one line: def f(items=None): if items is None: items = []. Or use an immutable default: def f(items=()): items = list(items). Or make the argument required: def f(items):. All three work. What doesn't work is leaving def f(items=[]) in your code. Grep for it. It's a linter rule (pylint W0102, flake8-bugbear B006) for a reason — this bug has been shipped to production thousands of times.
⚠️ Common mistakes
The call-by-sharing mistakes that cost real time.
Mutations inside the function affect the caller. Fix: copy first, or document the mutation.
Reassigning the parameter doesn't affect the caller. Fix: return the new value.
The [] is shared across calls. Fix: None + if is None.
Ambiguous API. Fix: mutate and return None, or return a new list without mutating.
s.upper() returns new; doesn't modify. Fix: s = s.upper().
Skipped items, wrong values. Fix: iterate a copy, or build a new list.
class Bag: items = [] — shared. Fix: self.items = [] in __init__.
Holding a reference to a mutable object that might change. Fix: list(items) before storing.
❓ Frequently asked
Is Python pass by reference or pass by value?
Neither — Python uses call by sharing. When you pass an argument, the function receives a reference to the same object. It can mutate the object (if mutable), but it can't rebind the caller's variable. This is the behavior of Java, JavaScript, Ruby, C#, and many other languages.
Why does modifying a list inside a function change the original?
Because the function received a reference to the same list object. Example: def add(items): items.append(4); my_list = [1, 2, 3]; add(my_list); print(my_list) — [1, 2, 3, 4]. The items parameter in the function is a new name bound to the same list object. Mutating it affects the object both names point to.
Why doesn't reassigning the parameter affect the caller?
Because the parameter is a new name, not a reference to the caller's variable. Example: def reassign(items): items = []; my_list = [1, 2, 3]; reassign(my_list); print(my_list) — [1, 2, 3]. Inside the function, items = [] rebinds the local name items to a new list. The caller's my_list still points to the original [1, 2, 3].
What is the mutable default argument trap?
Default arguments are evaluated once, at function definition time. A mutable default (like []) is created once and shared across all calls. Example: def bad(items=[]): items.append('x'); return items; bad() gives ['x'], bad() gives ['x', 'x'], bad() gives ['x', 'x', 'x']. Fix: use None as the default and create a new list inside.
How do I pass a list to a function and modify it safely?
Two approaches. (1) Mutate in place — the function accepts a list, modifies it, and the caller sees the change. Document this clearly. (2) Return a new list — the function accepts a list, doesn't mutate it, and returns a new one. Choose based on API design: in-place is efficient for large lists; functional is safer.
Are integers and strings affected by call by sharing?
They're affected but the effect is invisible, because they're immutable. When you pass an int to a function, the function receives a reference to the int. But ints can't be mutated — you can only rebind. Rebinding inside the function doesn't affect the caller's variable. This is why beginners often say 'Python is pass by value' — for immutables, it looks that way.
🔗 Continue learning
Next in this series: