DEV Community

Cover image for Python’s Silent Production Killer: Why "Pass-by-Reference" is a Lie
HarshKumar Jha
HarshKumar Jha

Posted on • Originally published at linkedin.com

Python’s Silent Production Killer: Why "Pass-by-Reference" is a Lie

If you spend enough time writing backend Python code, you will eventually ship a "ghost bug."

You know the type. A web server where User B somehow sees a cached item belonging to User A. A background worker that mysteriously accumulates errors from previous jobs. A list that just keeps growing, defying all logic and garbage collection.

I've lost count of how many times I've traced these production incidents back to a single, fundamentally misunderstood concept: how Python actually handles variables and default arguments.

If you learned Python after C++ or Java, you probably learned that Python is "pass-by-reference." I want you to forget that phrase today. It’s a lie, and teaching it that way is exactly why these bugs keep making it into production.

Let’s go to ground zero. Once you see the matrix of how Python actually works, you will never write a bug like this again.


1. Variables are NOT Boxes. They are Sticky Notes.

In languages like C, a variable is a physical box in memory. If you say int x = 5, you are putting the value 5 inside the box named x.

In Python, the only real things are objects. Variables don't hold data; they are just sticky notes (labels) slapped onto objects that float around in memory.

When you type:

x = [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

Python creates a list object in memory (let's say at address 0x1234). Then, it takes a sticky note, writes x on it, and points it at that list.

The "Call by Sharing" Secret

Because variables are just sticky notes, passing a variable into a function does not copy the list, nor does it pass the variable itself. It just creates a new sticky note.

Let’s look at two functions that look similar but do entirely different things to our memory:

def mutate(lst):
    lst.append(99)         # Mutates the shared object

def rebind(lst):
    lst = [1, 2, 3]        # Peels off the sticky note and puts it on a NEW object

x = [10, 20]
Enter fullscreen mode Exit fullscreen mode

What happens during mutate(x)?
Python creates a local sticky note called lst and attaches it to the exact same list object that x is attached to. When we call .append(99), we are telling the object itself to change. Because x is still looking at that same object, x sees the mutation.
(Result: x becomes [10, 20, 99])

What happens during rebind(x)?
Python again creates a local sticky note called lst attached to our object. But then we execute lst = [1, 2, 3]. The = sign does not change the object! It simply peels the lst sticky note off the old object and slaps it onto a brand new list. The original list and the x sticky note attached to it remain completely untouched.
(Result: x stays [10, 20, 99])

Mutate and Rebind working


2. The Mutable Default Trap (A.K.A. The Ghost Bug)

Now that we have our sticky notes mental model, let's look at the classic bug that causes so much pain in production:

def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item(1))      # Outputs: [1]
print(add_item(2, []))  # Outputs: [2]
print(add_item("a"))    # Outputs: [1, 'a']  <-- Wait, what?!
Enter fullscreen mode Exit fullscreen mode

Where did the 1 come from in that last call? Why didn't we get a fresh, empty list?

A very reasonable question I hear from developers is: "If Python is interpreted, and the function returns, why doesn't the Garbage Collector clean up items=[] so the next call is fresh?"

Under the Hood: Functions are Objects

Here is the ground-zero truth that most tutorials skip: In Python, a function is an object. When Python reads your def statement, it doesn't just register the function name. It actually evaluates your default arguments exactly once at definition time. It then stores that default list as a permanent attribute on the function object itself.

Conceptually, Python builds a class for your function that looks like this:

# Pseudo-mental model of what Python does under the hood
class FunctionObject:
    def __init__(self):
        self.__name__ = "add_item"
        # The ONE list is created once and stored here forever
        self.__defaults__ = ([],)  
Enter fullscreen mode Exit fullscreen mode

That empty list [] is physically attached to the add_item function inside a hidden tuple called __defaults__.

Let's trace our execution:

  1. add_item(1): You didn't provide a list. Python says, "I'll use my default!" It grabs the list from __defaults__. You append 1. That default list is now permanently [1].
  2. add_item(2, []): You explicitly passed a fresh list. Python uses your new list. The default list sits safely untouched.
  3. add_item("a"): You didn't provide a list. Python goes back to its __defaults__. But remember, that list was mutated in step 1! It grabs the list [1], appends "a", and now your default list is permanently [1, "a"].

How actually the params works

This isn't a garbage collection issue. The Garbage Collector won't touch that list because the function object still holds a reference to it. As long as your process is running, that list is alive.

If this is a web server or a background worker, that default list just became a globally shared state across completely different user requests.


3. The Defensive Fix (Boring is Better)

The fix for this is simple, standard, and should be universally enforced in every serious Python codebase:

def add_item(item, items=None):
    if items is None:
        items = []   # Fresh list created AT CALL TIME, inside the function
    items.append(item)
    return items
Enter fullscreen mode Exit fullscreen mode

By using None as the default, you are using an immutable, safe object. Then, inside the function body, you instantiate the new list. Because this instantiation happens inside the function, a brand new list object is created on the heap every single time the function runs.

No shared state. No ghost bugs.


Over to You

Changing your mental model from "variables are boxes" to "variables are sticky notes" eliminates 90% of the weirdness in Python.

I’m always refining how I explain these core concepts to the developers I mentor, so I’d love to hear from other tech leads, senior engineers, and Python devs reading this:

  1. How do you explain "names vs. objects" to your team without putting them to sleep with C-level memory diagrams?
  2. Do you enforce a strict linter rule (like Flake8's B006) banning mutable defaults in your codebase?
  3. What is the worst production incident you've ever seen caused by this exact gotcha?

Drop your explanations or war stories in the comments. I read all of them, and I guarantee the newer Python developers reading this thread will learn more from your real-world mistakes than from any official documentation.

Top comments (0)