Pass by Reference vs Value in Python: A Practical Mental Model

I keep seeing the same confusion when teams move between Python and languages like C++ or Java: “Is Python pass by value or pass by reference?” The short answer is neither in the traditional sense. The useful answer is that Python passes object references, and the behavior you observe depends on whether the object can be mutated in place. That distinction sounds academic until a production bug shows up because a helper function “mysteriously” changed a list that other code assumed was stable. I’ve fixed more of those than I’d like to admit.

In this post I walk you through how I reason about argument passing in Python, how mutability changes the story, and the specific patterns I use to avoid surprising side effects. I’ll show runnable examples, point out common mistakes, and give concrete guidance for when to mutate and when to return new objects. I’ll also talk about performance, because copying data structures has real costs at scale. My goal is to help you build an intuition that survives real-world codebases, not just whiteboard explanations.

The mental model I use: object references, not copies

When you pass an argument into a Python function, the function receives a reference to the same object the caller holds. That reference is a pointer-like handle to an object in memory. The key detail is that assignment inside the function can either mutate the object or rebind the local name to a different object.

Here’s the model I keep in my head:

  • Every Python name is a label that points to an object.
  • Function calls create new labels for the same objects.
  • Mutating the object affects all labels that point to it.
  • Rebinding a label affects only that label’s scope.

This model explains why Python is often described as “call by sharing.” The object is shared, but the name is not. That’s different from pass by value (copying the object) and different from classic pass by reference (where the callee can replace the caller’s variable itself).

If you come from C++, think of it this way: Python effectively passes a pointer by value. You can mutate through the pointer, but you can’t change which pointer the caller holds.

Mutable objects: the aliasing effect you can feel

Mutable types are the ones that bite you: lists, dicts, sets, custom classes with mutable fields, and many third-party objects. If you modify them in place inside a function, the caller sees the change immediately.

Here is a small example that demonstrates aliasing (two names, one object):

def add_tag(tags):

# Mutates the list in place

tags.append("urgent")

issue_tags = ["backend", "api"]

addtag(issuetags)

print(issue_tags)

Output:

[‘backend‘, ‘api‘, ‘urgent‘]

I often use this pattern on purpose, for example when I want a function to enrich a shared data structure. But I make it explicit in the function name and documentation. When mutation is intentional, I use verbs like add, attach, or mutate_ so the next reader knows what’s going to happen.

Rebinding is not mutation

A common confusion is that reassignment inside a function “should” affect the caller. It doesn’t. You’re just changing the local label.

def replace_tags(tags):

# Rebinds the local name to a new list

tags = ["triaged"]

return tags

issue_tags = ["backend", "api"]

replacetags(issuetags)

print(issue_tags)

Output:

[‘backend‘, ‘api‘]

replace_tags created a new list and changed the local name tags to point to it. The caller still points to the original list. This is why I say “you can mutate the object, but you can’t swap the caller’s label.”

In-place vs rebinding side by side

Seeing both in one block makes the distinction stick:

def update_status(statuses):

statuses.append("running") # in-place mutation

def reset_statuses(statuses):

statuses = ["idle"] # rebinding

live = ["starting"]

update_status(live)

reset_statuses(live)

print(live)

Output:

[‘starting‘, ‘running‘]

Only the append survives outside. The rebinding is local.

Immutable objects: why it feels like pass by value

Immutable types include int, float, str, tuple, and frozenset. When you “change” them, you actually create a new object. That makes it look like pass by value, even though the same pass-by-object-reference rules still apply.

def add_fee(amount):

amount = amount + 2.50 # creates a new float

print("Inside:", amount)

price = 9.99

add_fee(price)

print("Outside:", price)

Output:

Inside: 12.49

Outside: 9.99

Here the reference in the function points to a new float after the addition, while the caller still points to the original float. I often explain this with a simple analogy: an immutable object is like a printed receipt. You can’t change the ink; you can only print a new receipt.

Strings: especially confusing for newcomers

Strings are immutable, but Python lets you write operations that look mutating:

def normalize_city(name):

name = name.strip().title()

return name

city = " san francisco "

normalize_city(city)

print(city)

Output:

  san francisco

The original string stays the same unless you assign the returned value. I tell people to treat every string method as “returns a new string.”

Call by sharing in practice: what the function can and can’t do

I think it’s useful to spell out the capabilities explicitly:

  • The function can mutate a passed-in object if that object is mutable.
  • The function cannot make the caller’s name point somewhere else.
  • The function can return a new object, and the caller can decide whether to use it.

This is why the most reliable way to “change the caller’s value” is to return a new object and assign it at the call site. I use that approach for any logic that might surprise someone.

Here’s a pattern I like for clarity:

def apply_discount(cart, percent):

# Returns a new list to avoid mutating caller state

updated = []

for item in cart:

updated.append({

item,

"price": round(item["price"] * (1 - percent / 100), 2)

})

return updated

cart = [

{"sku": "A12", "price": 19.99},

{"sku": "B07", "price": 5.00},

]

newcart = applydiscount(cart, 10)

print(cart)

print(new_cart)

Output:

[{‘sku‘: ‘A12‘, ‘price‘: 19.99}, {‘sku‘: ‘B07‘, ‘price‘: 5.0}]
[{‘sku‘: ‘A12‘, ‘price‘: 17.99}, {‘sku‘: ‘B07‘, ‘price‘: 4.5}]

I like this because it makes intent explicit: no hidden mutations.

Common mistakes I see (and how I fix them)

1) Mutating defaults

This is the classic Python trap. Default argument values are evaluated once, so the same object is reused.

def add_log(entry, logs=[]):

logs.append(entry)

return logs

print(add_log("start"))

print(add_log("continue"))

Output:

[‘start‘]
[‘start‘, ‘continue‘]

If you want a fresh list each time, use None and create the list inside:

def add_log(entry, logs=None):

if logs is None:

logs = []

logs.append(entry)

return logs

2) In-place mutation in helper utilities

If a helper modifies a list or dict, callers often don’t expect it. I treat helper functions as pure by default, unless the function name signals mutation clearly.

Bad surprise:

def sanitize_headers(headers):

headers.pop("Authorization", None)

return headers

Better clarity:

def sanitized_headers(headers):

safe = dict(headers)

safe.pop("Authorization", None)

return safe

I prefer the second in shared libraries. It costs a shallow copy but prevents subtle bugs.

3) Forgetting nested mutability

A shallow copy breaks the top-level link but not nested objects.

def add_item(order):

cloned = dict(order)

cloned["items"].append("gift")

return cloned

order = {"items": ["book"]}

updated = add_item(order)

print(order)

Output:

{‘items‘: [‘book‘, ‘gift‘]}

You still mutated the original because the list inside is shared. The fix is a deep copy or rebuilding nested objects intentionally:

import copy

def additemsafe(order):

cloned = copy.deepcopy(order)

cloned["items"].append("gift")

return cloned

I tend to rebuild nested objects explicitly in performance-critical code instead of using deepcopy, which can be slow and opaque.

Choosing mutation or return-new: the rules I follow

I use a few simple guidelines in real projects:

1) If the function name sounds like an action on the object (add, remove, update), mutation is fine, but I make it explicit in docs and tests.

2) If the function name sounds like a transformation (normalize, sanitize, format), I return a new object.

3) If the object might be shared across threads, tasks, or async contexts, I avoid mutation unless I control access.

4) If the object is a configuration or environment map, I never mutate it; I return a new mapping.

Here’s a table I use when mentoring teams to reinforce the idea:

Context

Traditional choice

Modern Python choice I recommend —

— Small utility function

In-place mutation

Return new object unless mutation is obvious Performance-critical loop

Copy for safety

Mutate in place with clear naming Shared config dict

Copy to avoid side effects

Always return a new dict Data pipeline step

Mixed styles

Pure transform functions with explicit outputs Async workflows

Ad-hoc mutation

Treat inputs as immutable, return new

In 2026, with AI-assisted code review and static analysis tools getting better, I see teams shifting toward pure functions because they are easier for humans and tools to reason about. Mutation still has a place, but I treat it as an advanced tool that should be used with intent.

Real-world scenarios that clarify the behavior

Scenario 1: API client headers

I’ve seen API clients that mutate a shared headers dict, then later requests “mysteriously” include a bearer token that should have been removed.

My fix pattern:

def with_auth(headers, token):

# Return new mapping, do not mutate shared headers

updated = dict(headers)

updated["Authorization"] = f"Bearer {token}"

return updated

Now each request can decide whether to include the token without polluting a global header object.

Scenario 2: Caching in ML inference

A preprocessing function may take a dict, add derived features, and pass it along. If you reuse the original dict across batch items, mutation can leak data between runs.

I prefer:

def build_features(record):

# build a new dict, no mutation

return {

record,

"name_length": len(record["name"]),

"has_discount": record["price"] < 10,

}

This prevents cross-request contamination, which is brutal to debug in async inference services.

Scenario 3: Time-series enrichment

When you have a list of measurements and add derived values, mutation can be a win when the list is local and not shared.

def add_velocity(points):

for i in range(1, len(points)):

dx = points[i]["x"] - points[i-1]["x"]

dt = points[i]["t"] - points[i-1]["t"]

points[i]["vx"] = dx / dt

Here I’m okay mutating because the data is local to the pipeline step and not used elsewhere.

Performance considerations that actually matter

Copying objects costs time and memory. For small lists or dicts, the overhead is usually tiny, often in the low milliseconds even for hundreds of items. But for large structures or hot loops, that cost adds up quickly.

The trade-offs I use in practice:

  • Shallow copy is cheap; deep copy can be expensive, sometimes tens of milliseconds for large nested structures.
  • Mutating large lists in place can save a lot of memory churn and garbage collection.
  • For immutables like integers or strings, you pay the cost of creating new objects regardless, so returning new values is the default.

I tell teams to start with clarity, then measure. If a profiler shows copying is a bottleneck, then we can decide where mutation is safe. In data-heavy services, I’ve seen a 15–30% reduction in peak memory simply by mutating a local list in place instead of building a new one each step. But I only do that after I know the list is not shared.

Testing strategies that catch mutation bugs

I’m big on tests that make side effects visible. Here are two simple patterns I use.

1) Snapshot before and after

def testnormalizedoesnotmutate_input():

original = {"city": " denver "}

copy = dict(original)

result = normalizecityrecord(original)

assert original == copy

assert result["city"] == "Denver"

If the function mutates, the first assertion fails. It’s cheap and clear.

2) Identity checks for mutation expectations

If mutation is intended, I assert it.

def testaddtagmutateslist():

tags = ["api"]

result = add_tag(tags)

assert result is tags

assert tags == ["api", "urgent"]

Using is makes intent explicit: the function returns the same object and mutates it.

Passing references in custom classes

Custom objects follow the same rules. If your class exposes mutable fields, those can be modified in place.

class Invoice:

def init(self, lines):

self.lines = lines

def add_line(invoice, line):

invoice.lines.append(line)

inv = Invoice(lines=["A12"])

add_line(inv, "B07")

print(inv.lines)

Output:

[‘A12‘, ‘B07‘]

If you want safer behavior, you can design your classes as immutable or provide methods that return new instances. Python’s dataclasses with frozen=True is a good start, but remember that frozen classes can still contain mutable fields. For safety, I pair frozen dataclasses with tuples or other immutable containers.

When I recommend “pass by reference style” in Python

Even though Python doesn’t have pass by reference in the C++ sense, I sometimes design APIs to feel like it. These are the cases:

  • Builders that gradually assemble a structure, like request payloads or HTML fragments.
  • Performance-critical data pipelines where copying is expensive and data is not shared.
  • Scoped state objects in a single-threaded context, where mutation is obvious and local.

When I do this, I make the mutating behavior part of the interface contract. The function name and docstring carry the signal, and tests enforce it.

When I recommend “pass by value style” in Python

I push for return-new behavior in these cases:

  • Shared configuration objects.
  • Library functions used across teams or services.
  • Async or concurrent workflows where side effects are hard to reason about.
  • Code that will be used by data scientists or analysts who don’t expect mutation.

In those cases, the clarity is worth the extra memory cost. Plus, modern Python code reviews often include automated reasoning tools in 2026 that flag hidden mutations.

The aliasing trap: how the same object sneaks into two places

One of the easiest ways to get confused about argument passing is to unintentionally create aliases. An alias is just another name pointing to the same object. This can happen in two common ways:

1) You pass the same object into multiple functions.

2) You store the same object in multiple data structures.

Example of aliasing by storage:

shared = {"count": 0}

containers = [shared, shared, shared]

def bump(counter):

counter["count"] += 1

bump(containers[0])

print(containers[1])

Output:

{‘count‘: 1}

That output surprises people because they don’t realize all three list entries reference the same dict. The fix is to create distinct objects:

containers = [{"count": 0} for _ in range(3)]

When argument passing feels unpredictable, I start by searching for aliasing. If two code paths mutate the same object, the bug becomes “Heisenberg-style” — it appears or disappears depending on what else runs.

Shallow vs deep copy: the practical difference

I already showed how shallow copies still share nested objects. The important practical question is: when should you use shallow copies, and when do you need deep copies?

I use this rule of thumb:

  • If the structure is flat (dict of ints/strings), shallow copy is enough.
  • If the structure contains nested lists/dicts you plan to mutate, deep copy or explicit reconstruction is safer.

Here’s a quick demonstration:

profile = {

"name": "Ada",

"roles": ["admin", "editor"],

}

flat = dict(profile) # shallow

flat["name"] = "Grace" # safe

flat["roles"].append("owner") # unsafe

print(profile)

Output:

{‘name‘: ‘Ada‘, ‘roles‘: [‘admin‘, ‘editor‘, ‘owner‘]}

If I actually need an independent structure, I either rebuild the nested fields or deep copy:

import copy

safe = copy.deepcopy(profile)

safe["roles"].append("owner")

The key is to be explicit about what you expect to share. That expectation is the real design decision.

Edge cases that surprise even experienced devs

Case 1: Tuples that contain mutable objects

Tuples are immutable, but they can contain mutable elements. This looks safe but isn’t:

settings = ("dark", {"font": "serif"})

def update_font(cfg):

cfg[1]["font"] = "mono"

update_font(settings)

print(settings)

Output:

(‘dark‘, {‘font‘: ‘mono‘})

You didn’t mutate the tuple, but you mutated the dict inside it. The same applies to frozen dataclasses or namedtuples that hold mutable objects.

Case 2: Default dict values that are mutable

Even if you avoid mutable default arguments in function signatures, you can still embed mutable defaults in objects:

class User:

def init(self, tags=None):

self.tags = tags or []

That looks fine, but now every User created with tags=None shares the same list? Actually no — tags or [] creates a new list each call, which is safe. The risky version is:

class User:

def init(self, tags=[]):

self.tags = tags

That version shares the same list across instances. It is the same trap in a different shape.

Case 3: Caching and memoization

Memoized functions often return objects stored in a cache. If the caller mutates the returned object, they mutate the cached value too.

_cache = {}

def getprofile(userid):

if userid not in cache:

cache[userid] = {"id": user_id, "visits": 0}

return cache[userid]

profile = get_profile(1)

profile["visits"] += 1

print(get_profile(1))

Output:

{‘id‘: 1, ‘visits‘: 1}

If the cache is intended to be immutable snapshots, return a copy. If it’s intended to be shared mutable state, document that clearly and consider thread safety.

Alternative approaches to avoid surprises

If you’re building a system where mutation is a constant source of bugs, there are patterns that help without rewriting everything.

1) Embrace immutability with data classes

I often create data containers using frozen dataclasses and immutable fields to make mutation harder by default.

from dataclasses import dataclass

@dataclass(frozen=True)

class Point:

x: float

y: float

This doesn’t stop you from mutating nested fields if they are mutable, but it discourages rebinding. For most data records, this is enough to keep reasoning simple.

2) Return new objects from methods

Instead of mutation methods like update, you can offer methods that return a new instance:

from dataclasses import dataclass

@dataclass(frozen=True)

class CartItem:

sku: str

price: float

def discounted(self, percent):

return CartItem(self.sku, self.price * (1 - percent / 100))

This style is common in functional programming but works great in Python for critical components.

3) Use copy-on-write patterns

For large objects, you can avoid full copies by using copy-on-write semantics: make shallow copies and only clone nested data if mutation is needed.

class Config:

def init(self, data):

self._data = data

def with_override(self, key, value):

newdata = dict(self.data)

new_data[key] = value

return Config(new_data)

This is a practical middle ground: predictable behavior without deep copies everywhere.

A deeper comparison: Python vs C++ vs Java mental models

When people ask “Is Python pass by reference or value?” they’re usually trying to map to a familiar language. The mismatch is what causes confusion.

  • In C++, pass by value copies; pass by reference allows rebinding the caller’s variable. Python doesn’t allow rebinding the caller’s variable at all.
  • In Java, object references are passed by value. That’s closer to Python’s behavior. You can mutate through the reference, but you cannot reassign the caller’s variable.
  • In Python, everything is an object, and names are labels to those objects. The semantics are consistent across types; the mutability of the object drives the behavior.

The result is: Python feels like pass by value for immutables and pass by reference for mutables, but the underlying model is consistent in both cases.

Practical patterns for safe APIs

When I design APIs in Python, I ask myself two questions:

1) Will a caller reasonably expect their object to be modified?

2) Is this object likely to be shared across different parts of the codebase?

If the answer to either is “no,” I return a new object. If the answer is “yes,” I mutate and document it.

Here are patterns I use to communicate intent clearly.

1) Naming conventions

  • add, remove, update, merge suggests mutation.
  • normalized, formatted, sanitized_ suggests returning a new object.

Names are a contract. They do a surprising amount of work in code review.

2) Docstring warnings

Even a one-line docstring can prevent bugs:

def add_tag(tags):

"""Mutates tags in place by appending ‘urgent‘."""

tags.append("urgent")

return tags

3) Returning the same object when mutating

If I mutate, I return the same object so that chaining is possible and intent is obvious:

def add_tag(tags):

tags.append("urgent")

return tags

Then callers can choose to ignore the return if they want, but tests can assert identity.

Mutation in concurrent and async code

This is where pass-by-object-reference causes real production issues. In async workflows or threaded contexts, two tasks can see the same mutable object and race to change it.

Consider this (simplified):

import asyncio

async def enrich(data):

await asyncio.sleep(0.01)

data["score"] += 1

async def main():

shared = {"score": 0}

await asyncio.gather(enrich(shared), enrich(shared))

print(shared)

asyncio.run(main())

Depending on scheduling, you’ll get {‘score‘: 2}, but you can also get inconsistent values if more complex operations are involved. The safe approach is to avoid shared mutable state between tasks, or guard it with locks. In most application code, returning new objects is simpler and safer than fine-grained synchronization.

How I communicate “pass by sharing” to new team members

When I onboard engineers who are used to C++ or Java, I use a quick script to make the concepts tangible:

# 1) Show shared mutation

def bump(lst):

lst.append(99)

nums = [1, 2]

bump(nums)

print(nums) # mutation visible

2) Show rebinding does not escape

def rebind(lst):

lst = [42]

nums = [1, 2]

rebind(nums)

print(nums) # unchanged

Then I summarize in one sentence: “In Python, you can mutate objects you receive, but you can’t reassign the caller’s variables.” That line tends to stick.

Practical scenario: data validation pipelines

Validation is a great place to be explicit about mutation. Consider a pipeline that cleans input data and records errors.

Mutation-heavy approach:

def validate(record, errors):

if "email" not in record:

errors.append("missing email")

if "name" in record:

record["name"] = record["name"].strip()

record = {"name": " Ada "}

errors = []

validate(record, errors)

This mutates both record and errors. Sometimes that’s desirable (e.g., streaming validation), but it can be surprising.

Return-new approach:

def validated(record):

errors = []

cleaned = dict(record)

if "email" not in cleaned:

errors.append("missing email")

if "name" in cleaned:

cleaned["name"] = cleaned["name"].strip()

return cleaned, errors

This returns both the cleaned data and errors, making side effects explicit. It is easier to reason about, especially in tests.

Practical scenario: logging and monitoring hooks

Hooks often receive objects by reference. A common bug is a hook that mutates data just to log it:

def log_request(request):

# Oops: this pops a field and changes actual request data

request["headers"].pop("Authorization", None)

print(request)

Instead, I explicitly copy before logging:

def log_request(request):

safe = dict(request)

safe["headers"] = dict(request.get("headers", {}))

safe["headers"].pop("Authorization", None)

print(safe)

This is a case where shallow vs deep copy matters. I copy only the parts I plan to mutate for logging.

Practical scenario: retry logic and exponential backoff

Retries often involve mutating a request payload (adding headers, incrementing counters). If that payload is shared, retries can corrupt the original intent.

I prefer to treat outgoing payloads as immutable snapshots and return a new one with metadata:

def withretryinfo(payload, attempt):

updated = dict(payload)

updated["retry_attempt"] = attempt

return updated

That keeps the base payload clean and makes retries more predictable.

Practical scenario: pandas and numpy objects

Data science libraries are heavy on mutability. A DataFrame passed into a function can be mutated in place even if you don’t expect it, because many operations default to in-place behavior.

I use a simple rule: if a function is in a shared codebase, it should avoid inplace=True unless documented. I make a copy at the boundary:

import pandas as pd

def normalize_prices(df):

local = df.copy()

local["price"] = local["price"] / local["price"].max()

return local

This isn’t about pandas specifically; it’s about setting clear expectations. If the calling code wants mutation for performance, it can do that explicitly.

Debugging techniques for mutation bugs

When mutation bugs occur, they can be hard to trace. I use a few tricks:

1) Log object identities

print(id(obj))

If two variables have the same id, they are the same object. That instantly confirms aliasing.

2) Use is in assertions

In tests, I often assert identity:

assert result is original

That clarifies whether the function is supposed to return the same object or a new one.

3) Freeze objects in debug builds

Sometimes I wrap dicts in custom classes that raise errors on mutation during debugging. This is heavy-handed but effective for persistent issues. The idea is to catch mutation early rather than after it has cascaded.

A small checklist I use in code review

When I review Python code, I mentally check these points:

  • Are there any functions that mutate arguments without clear naming?
  • Are defaults safe (no mutable defaults)?
  • Are there shallow copies that should be deep copies?
  • Are cached objects being returned directly and then mutated?
  • Are async or threaded contexts sharing mutable objects?

If two or more of these pop up, I usually request changes. It’s almost always cheaper to fix the mutation story early than to debug it later.

A quick reference: what is mutable in Python?

This is the short list I keep in my head for everyday coding:

Mutable:

  • list, dict, set
  • bytearray
  • most class instances
  • pandas DataFrame, numpy arrays, and many third-party objects

Immutable:

  • int, float, bool
  • str, tuple, frozenset
  • bytes

The tricky part is custom objects: a frozen dataclass can still hold a mutable list. So “immutable” is only as good as the fields you store.

Common pitfalls recap (with fixes)

I like to remind myself of the top offenders:

1) Mutable defaults → Use None and create a new object inside.

2) Shallow copy with nested data → Use deep copy or rebuild nested objects.

3) Mutating cached objects → Return a copy or document shared state.

4) Misleading helper names → Choose names that signal mutation vs transformation.

5) Async sharing → Avoid shared mutable state, return new objects.

These five account for a large chunk of real-world bugs I’ve seen around argument passing in Python.

How I teach the difference between mutation and rebinding

When the concept doesn’t stick, I use a whiteboard example with two arrows:

  • The variable name points to an object.
  • A second variable name can point to the same object.
  • Mutation changes the object; rebinding moves the arrow.

Then I back it with a tiny experiment:

x = [1, 2]

y = x

x.append(3)

print(y) # [1, 2, 3]

x = [9]

print(y) # still [1, 2, 3]

It’s simple, but it removes 80% of the confusion. From there, the function argument story becomes obvious: function parameters are just new names pointing to the same objects.

Decision matrix: mutation vs return-new

If you want a quick decision aid, here is the one I actually use:

  • Is the object shared across modules or tasks? Return new.
  • Is the object local to a tight loop and performance matters? Mutate.
  • Would a new teammate expect mutation? If unsure, return new.
  • Does the function name imply mutation? If yes, mutate.
  • Can I easily test for side effects? If not, return new.

This is not a universal law, but it keeps my codebase consistent and predictable.

Production considerations: monitoring and debugging at scale

In large systems, mutation bugs often appear as data drift, inconsistent caches, or non-reproducible errors. I’ve seen issues where a shared config dict got mutated by one service, and the mutation propagated into other services through long-lived objects.

Two production-oriented techniques help:

1) Immutability at boundaries. Treat inputs from external systems as immutable snapshots and return new objects from processing stages.

2) Defensive copying in APIs. If an API accepts a dict and you need to store it, copy it first so the caller can’t mutate your internal state later.

These practices are boring, but they save you from incident calls at 2 a.m.

Why “pass by reference vs value” is the wrong question

The reason the debate never ends is that it’s a false dichotomy. Python does neither in the traditional sense. It passes references by sharing. That makes it look like pass by value with immutables, pass by reference with mutables, and then confusing if you expect rebinding to leak back to the caller.

Once you adopt the mental model — names point to objects, function parameters are new names — the confusion disappears. Everything else (mutation, copying, performance) becomes a design choice rather than a mystery.

The short version I keep in my head

If you want the 20‑second summary I tell coworkers:

  • Python passes object references.
  • Mutation changes the shared object; rebinding is local.
  • Immutables make it feel like pass by value.
  • Use mutation intentionally, not accidentally.

That’s the story. The rest of this post is just practical detail on how to make it work in real codebases.

Closing thoughts

I used to view this topic as “Python trivia.” I don’t anymore. Understanding how arguments are passed is a core skill that affects correctness, performance, and maintainability. The difference between mutating and returning new objects shows up everywhere: APIs, data pipelines, ML inference, configuration handling, concurrency, and even logging.

The payoff is huge. Once you internalize the model, you stop being surprised, your code review discussions get easier, and bugs that used to take hours to diagnose become obvious in minutes.

If you take one thing away, let it be this: mutation is a tool, not a default. Python gives you the freedom to choose. The quality of your codebase depends on how intentionally you use that freedom.

Scroll to Top