Python Mutable Defaults — Why Users Saw Each Other's Errors
One shared list caused cross-user data leaks under load.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Python 3.8+, basic understanding of Python syntax, familiarity with mutable vs immutable types, experience with exception handling, basic knowledge of function arguments and variable scope
- Mutable default args (list, dict) are created once at function definition — shared across all calls
- == checks value equality; is checks object identity — use is only for None, True, False
- Removing items from a list while iterating skips elements — iterate over a copy or use list comprehension
- Assignment (=) never copies — use [:] for flat lists, copy.deepcopy() for nested structures
- Variable scope follows LEGB: local, enclosing, global, built-in — assignment inside a function creates a local unless declared global
- Performance insight: List comprehensions are ~2x faster than manual for-loops with .append()
- Production insight: Mutable defaults can silently corrupt cross-user data in microservices — always use None as sentinel
Imagine you're baking a cake and the recipe says 'add sugar to taste' — but you accidentally add salt every single time because the containers look identical. Python mistakes work the same way: the code looks right, it even runs sometimes, but it quietly does the wrong thing. These aren't random errors — they're predictable traps that almost every new Python developer falls into. Once you know where the trapdoors are, you'll never fall through them again.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Python is famous for being beginner-friendly, and that reputation is well-earned. But 'easy to start' doesn't mean 'impossible to mess up.' In fact, Python's clean syntax can lull you into a false sense of security — you write code that looks perfectly sensible, hit run, and get results that are completely wrong with no error message to warn you. These silent bugs are the most dangerous kind, because you don't even know something went wrong.
Most Python mistakes aren't random. They cluster around a handful of concepts that trip up beginners and even intermediate developers: how Python handles mutable objects, how it compares values versus identities, how indentation creates scope, and how default arguments are evaluated. Each of these is a 'gotcha' built into the language's design — not bugs in Python, but features that behave differently from what newcomers expect.
By the end of this article you'll be able to look at a piece of Python code and immediately spot these hidden traps. You'll understand WHY each mistake happens — not just what the fix is — so you can reason about new code confidently. We'll use real runnable examples, show you the exact wrong output and then the corrected version, and finish with the interview questions recruiters love to ask about exactly this stuff.
Why Mutable Defaults Corrupt Shared State
A mutable default argument in Python is an object (like a list or dict) that is created once at function definition time, not each time the function is called. This means every call that omits that argument shares the same underlying object. The core mechanic: Python evaluates default arguments when the def statement executes, not when the function is invoked. So def f(x=[]) creates a single list object stored in f.__defaults__. Each call that relies on the default mutates that same list, accumulating state across calls. This is a classic gotcha because it violates the assumption that each call gets a fresh default. In practice, this bites teams when a web handler or background worker uses a mutable default to accumulate errors or user data. Two users hit the same endpoint: the first triggers an error appended to the default list, the second sees that error as their own. The symptom is intermittent, non-reproducible bugs that vanish when you add logging or restart the process. The rule of thumb: never use a mutable object as a default argument. Use None instead, and assign a fresh mutable inside the function body.
def view(request, errors=[]) to collect validation errors. User A's errors appeared in User B's response.None as default and create a fresh mutable inside the function.None as the default for mutable parameters; instantiate inside the function.The Mutable Default Argument Trap — Python's Most Surprising Bug
Here's one that catches almost everyone. When you define a function with a default argument like def add_item(item, cart=[]), you might think Python creates a fresh empty list every time the function is called without a second argument. It doesn't. Python creates that default list exactly once — when the function is defined — and reuses the same list object every single time.
Think of it like a sticky notepad on your desk. You write a reminder on it, tear off the note, but the impression is still on the next page. Each function call writes on the same pad. This is because in Python, default argument values are stored as part of the function object itself, not re-evaluated on each call.
This is one of the most common Python bugs in production code. The fix is simple but important: use None as the default value, then create a new list inside the function body if the caller didn't provide one. This ensures every call that needs a fresh list actually gets one.
Here's the deeper reason: Python evaluates default arguments at definition time, not call time. That's by design — it avoids recalculating expensive default values repeatedly. But for mutable objects, this efficiency creates a shared-state bug that becomes a data corruption vector in multi-threaded or concurrent environments.
None and build the object inside the function body.== vs is — Equality Versus Identity (They Are Not the Same Thing)
Imagine twins who look identical. If you ask 'do they look the same?' the answer is yes. But if you ask 'are they the same person?' the answer is no. That's exactly the difference between == and is in Python.
== checks if two values are equal — like asking 'do these two things look the same?'. is checks if two variables point to the exact same object in memory — 'are these two variables the same physical thing?'. For beginners, these feel interchangeable. They're not, and using is where you mean == creates silent bugs that are very hard to track down.
The most dangerous version of this mistake is checking if some_variable is 'hello' instead of if some_variable == 'hello'. For small integers (-5 to 256) and short strings, Python caches the objects — so is accidentally works. But for larger values or strings built at runtime, it breaks without warning. Always use == for value comparison. Reserve is exclusively for checking against None, True, and False.
One nuance: interning is an implementation detail. CPython interns integer literals in a certain range and small strings that look like identifiers. Other Python implementations (PyPy, Jython) may not. Relying on is for value comparison is undefined behaviour across implementations.
is is for None, True, and False checks only. Everything else uses ==. If you ever find yourself writing if some_string is 'hello', stop — that's a bug waiting to happen, and Python 3.8+ will even show you a SyntaxWarning for it.is and returned stale data because runtime-generated strings had different identities.is for value comparison — it's not just style, it's correctness.is.is only for None, True, False — always use == for everything else.Modifying a List While Iterating Over It — The Vanishing Items Bug
Picture a queue of people at a coffee shop. You're going through the line one by one, and as you skip someone you don't like, you push everyone behind them one step forward. Now the person who was second is in first position — and you've already moved past first position. You just skipped someone without realising it.
That's exactly what happens when you remove items from a Python list while looping over it with a for loop. Python tracks your position in the list by index. When you delete an element, every element after it shifts one position to the left. Python moves forward anyway — skipping the element that just slid into the deleted slot. Items silently disappear from your processing.
The clean fix is to iterate over a copy of the list using list[:] or list(original), or better yet, use a list comprehension to build a new filtered list. List comprehensions aren't just stylistically preferred — they're actually the safest, most readable solution to this exact problem.
This bug is insidious because it only removes some items, not all, making the output inconsistent and hard to reproduce. Debugging often involves printing intermediate states to discover the skipped element.
RuntimeError: dictionary changed size during iteration. The fix is the same: iterate over list(my_dict.keys()) or build a new dict with a dict comprehension.my_list[:] or use a list comprehension.Misunderstanding How Python Copies Objects — Shallow vs Deep Copy
Imagine photocopying a folder. A shallow copy gives you a new folder with photocopies of the cover pages, but the pages inside still reference the originals — change a page inside, and both folders show the change. A deep copy photocopies every single page, so the two folders are completely independent.
In Python, when you assign one list to another variable with new_list = old_list, you haven't copied anything at all. You've given the same list a second name. Both variables point to the identical object. Change one and you change both.
Even new_list = old_list[:] only creates a shallow copy — which is fine for a flat list of numbers or strings, but fails for a list of lists. The nested inner lists are still shared. For true independence, you need from Python's built-in copy.deepcopy()copy module. Knowing when to use each is a sign of genuine Python understanding.
Performance note: is slower because it recursively copies every object. For large nested structures, consider whether you need full independence or can design your data to use immutable types.deepcopy()
= for aliases (intentional shared references), [:] or .copy() for flat lists of immutable items, and copy.deepcopy() when your data structure contains nested mutable objects. Using deepcopy everywhere is wasteful — it's slower and uses more memory.copy.deepcopy() for full independence in nested structures.Variable Scope and the LEGB Rule — Why Your Function Can't See the Variable
Python resolves variable names using the LEGB rule: Local, Enclosing, Global, Built-in. When you assign a value to a variable inside a function, Python assumes it's a local variable unless you explicitly declare it global (or nonlocal for nested functions). This leads to one of the most confusing errors for beginners: UnboundLocalError: local variable 'x' referenced before assignment.
Here's the trap: you have a global variable defined outside, and inside a function you try to modify it. Python sees the assignment and marks the variable as local. But if you also reference the variable before the assignment (e.g., print(x) then x = x + 1), Python throws an UnboundLocalError because the local x hasn't been assigned yet, even though a global x exists.
This is not a bug in Python — it's a deliberate design to prevent accidental modification of global state. The fix: either use global var_name to indicate you want to modify the global, or better, pass the variable as a parameter and return the updated value. Functions should avoid modifying globals whenever possible.
global sparingly — it makes code harder to test and reason about.Using Class Variables When You Meant Instance Variables — Shared State Surprise
A common object-oriented mistake is defining a mutable attribute directly in the class body (a class variable) and then modifying it on instances. Class variables are shared across all instances. If you modify a class variable through one instance, every instance sees the change — unless you accidentally create an instance variable with the same name.
Beginners often define a list or dict in the class body expecting each instance to get its own copy. But class variables are attached to the class itself, not to instances. When you access self.items and it doesn't exist on the instance, Python finds the class variable. If you assign to self.items (e.g., self.items.append(...) or self.items = ...), the behaviour differs: mutation finds the shared object, assignment creates a new instance variable that shadows the class variable.
The fix: define mutable defaults in __init__ using self.items = [] rather than in the class body. If you need a class-level constant, use immutable types like tuples or strings.
__init__ for mutable per-instance attributes.The Bare Except Trap: Swallowing KeyboardInterrupt and Your Sanity
You see a bare except: in code review and you should flag it immediately. This catches absolutely everything — including KeyboardInterrupt, SystemExit, and GeneratorExit. Hit Ctrl+C to stop a runaway script? Swallowed. Your app tries to shut down gracefully? Buried. Worse, bare excepts hide your own bugs. A typo in a variable name inside a try block becomes a silent no-op instead of a clear NameError. The code looks like it works, but it's lying to you. Always catch specific exceptions. If you truly need a safety net, use except Exception: — that still catches almost everything you'd want to catch, but lets KeyboardInterrupt and SystemExit propagate. And never, ever use except: pass. That's not error handling. That's code that gaslights you into thinking your application is stable when it's one bad input away from complete nonsense.
SystemExit(1) signals, making failed deployments look like successes. Your monitoring dashboard shows green while production is burning.The 'Lazy' Import That Breaks Code at 2 AM
I see this pattern everywhere: from module import *. It looks convenient. It's a ticking time bomb. When you star-import, you dump every public name from that module into your namespace. Tomorrow, that module updates and adds a function called . Guess what? Your local calculate() is now silently overwritten. No error. No warning. Your function now returns completely different results. Tests pass because they ran with the old version. Production breaks at 2 AM because someone ran calculate()pip install --upgrade. The fix is trivial and mandatory: import exactly what you need, or use the module namespace explicitly (import module; ). It's more typing. It's infinitely more maintainable. Your future self — awake at 2 AM — will thank you.module.function()
flake8 or ruff with --select=F403,F405 in CI. These rules flag wildcard imports and undefined names from star imports. It catches the bug before it hits production.Cross-User Data Contamination from Mutable Default Argument
def process_payment(amount, errors=[]) created the list once at import time. All concurrent requests sharing the same list object accumulated errors from every call.errors=None and created a new list inside the function with if errors is None: errors = []. Also added unit tests that called the function twice and asserted isolation.- Never use mutable objects as default arguments in Python.
- Always test concurrent behaviour when functions share mutable state.
- Use None as the sentinel default and create the mutable object inside the function body.
id(default_argument) inside the function to verify it's the same object across calls.is instead of ==. Log type(value) and id(value) to verify object identity. Use == for all value comparisons.for item in my_list[:]: to iterate over a copy, or refactor to a list comprehension.new = old (assignment) instead of new = old[:] (shallow copy). For nested lists, verify with id(new[0]) == id(old[0]) and switch to copy.deepcopy().global var_name inside the function to indicate you intend to modify the global. Alternatively, pass the variable as a parameter and return the updated value.print(f'id of default: {id(errors)}') inside the functionprint(func.__defaults__) to see the stored default objects| File | Command / Code | Purpose |
|---|---|---|
| mutable_default_argument.py | def add_to_cart_broken(item, cart=[]): | The Mutable Default Argument Trap |
| equality_vs_identity.py | shopping_list_a = ["milk", "eggs", "bread"] | == vs is |
| modify_list_while_iterating.py | temperature_readings = [22, -5, 30, -1, 18, -8, 25] | Modifying a List While Iterating Over It |
| shallow_vs_deep_copy.py | original_scores = [95, 87, 76, 88] | Misunderstanding How Python Copies Objects |
| scope_leqb.py | counter = 0 | Variable Scope and the LEGB Rule |
| class_vs_instance_vars.py | class ShoppingCart: | Using Class Variables When You Meant Instance Variables |
| dangerous_bare_except.py | def query_database(): | The Bare Except Trap |
| wildcard_import_disaster.py | def calculate(x): | The 'Lazy' Import That Breaks Code at 2 AM |
Key takeaways
None as your default and create the object inside the function body to avoid shared-state bugs.== checks if two values are the same; is checks if they're the same object in memory. Use is only with None, True, and Falsemy_list[:]) or use a list comprehension to build a filtered new list instead.=) never copies an object[:] for flat lists or copy.deepcopy() for nested mutable structures.global var_name; prefer passing state as parameters and returning updated values.__init__.Interview Questions on This Topic
What is a mutable default argument in Python, and why is it considered dangerous? Can you show me a broken example and then fix it?
def add(item, cart=[]). Python evaluates defaults once at function definition time, so every call that omits the argument uses the same mutable object. This causes data to accumulate between calls. Fix: use None as the default and create a new mutable object inside the function: if cart is None: cart = [].Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Advanced Python. Mark it forged?
7 min read · try the examples if you haven't