Home Python Python Mutable Defaults — Why Users Saw Each Other's Errors
Intermediate 7 min · March 08, 2026
Common Python Mistakes Every Developer Should Know

Python Mutable Defaults — Why Users Saw Each Other's Errors

One shared list caused cross-user data leaks under load.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Production
production tested
July 15, 2026
last updated
2,406
articles · all by Naren
Before you start⏱ 25 min
  • 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
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Common Python Mistakes Every Developer Should Know?

This article covers six of the most common and insidious mistakes that trip up Python developers, even experienced ones. These aren't syntax errors—they're logical traps that silently corrupt data, produce wrong results, or cause baffling runtime behavior.

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.

Each mistake stems from a fundamental misunderstanding of Python's object model, scoping rules, or evaluation semantics. If you've ever seen a user's error message appear in another user's session, or watched a list shrink unexpectedly while iterating, you've encountered one of these bugs.

The core problem in each case is that Python's design prioritizes simplicity and consistency over what a developer might intuitively expect. Mutable default arguments are evaluated once at function definition, not each call. == and is test completely different things—value equality vs. object identity.

Modifying a list while iterating shifts indices under your feet. Copying a list with = or even list() only copies references, not the nested objects. And Python's LEGB scope rule means inner functions can read but not assign to outer variables without nonlocal.

These aren't bugs in Python—they're features that punish assumptions.

Real-world consequences are severe. The mutable default argument bug famously caused a Django web app to show one user's form validation errors to another user, because the error list was shared across requests. A production system that compared objects with is instead of == silently failed for months because two identical database rows happened to be different Python objects.

These mistakes are why Python's official documentation and every senior dev's code review checklist explicitly warn against them. Understanding these six patterns will save you hours of debugging and prevent entire classes of production incidents.

Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

⚠ Common Misconception
It's not about mutability alone — it's that the default object is shared across calls. Even an empty list is dangerous if you mutate it.
📊 Production Insight
A Django view used def view(request, errors=[]) to collect validation errors. User A's errors appeared in User B's response.
Symptom: random cross-user data leaks in production, reproducible only under concurrent load.
Rule: always use None as default and create a fresh mutable inside the function.
🎯 Key Takeaway
Default arguments are evaluated once at definition time, not per call.
Use None as the default for mutable parameters; instantiate inside the function.
Never mutate a default argument — treat it as immutable shared state.
common-python-mistakes-every-developer-should-know Python Object Identity Architecture Layers of equality, identity, and mutability in Python Object Layer Mutable Objects | Immutable Objects Identity Layer id() function | is operator Equality Layer == operator | __eq__ method Copy Layer Shallow Copy | Deep Copy Scope Layer LEGB Rule | Class vs Instance THECODEFORGE.IO
thecodeforge.io
Common Python Mistakes Every Developer Should Know

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.

mutable_default_argument.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# ============================================================
# WRONG VERSION — using a mutable list as a default argument
# ============================================================
def add_to_cart_broken(item, cart=[]):
    # Python creates this [] ONCE when the function is defined
    # Every call that omits 'cart' shares the SAME list object
    cart.append(item)     # We're modifying the one shared list
    return cart

print("--- Broken version ---")
first_order = add_to_cart_broken("apple")
print(f"First order: {first_order}")   # Looks fine so far

second_order = add_to_cart_broken("banana")
print(f"Second order: {second_order}")  # Wait — why is apple here?!

print(f"First order now: {first_order}")  # Even first_order changed!


# ============================================================
# CORRECT VERSION — use None as the sentinel, build inside
# ============================================================
def add_to_cart(item, cart=None):
    # None signals "no cart was provided" — a safe, immutable default
    if cart is None:
        cart = []         # Create a BRAND NEW list for this call only
    cart.append(item)
    return cart

print("\n--- Fixed version ---")
first_order = add_to_cart("apple")
print(f"First order: {first_order}")    # ['apple']

second_order = add_to_cart("banana")
print(f"Second order: {second_order}")  # ['banana'] — clean!

print(f"First order still: {first_order}")  # ['apple'] — untouched
Output
--- Broken version ---
First order: ['apple']
Second order: ['apple', 'banana']
First order now: ['apple', 'banana']
--- Fixed version ---
First order: ['apple']
Second order: ['banana']
First order still: ['apple']
⚠ Watch Out:
This applies to ANY mutable default — lists, dicts, and sets all behave this way. The rule is: if your default value can be changed in place, never use it directly. Always use None and build the object inside the function body.
📊 Production Insight
A payment microservice used a mutable default list to track failed transactions.
Under load, error lists from different customers merged.
Rule: inspect all function signatures for mutable defaults during code review — this is not a style issue, it's a data integrity bug.
🎯 Key Takeaway
Mutable defaults are evaluated once at function definition time.
Every call without the argument shares the same object.
Never use [] or {} as defaults — use None and create inside.

== 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.

equality_vs_identity.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# ============================================================
# Demonstrating == (equality) vs is (identity)
# ============================================================

# --- Lists: same contents, different objects ---
shopping_list_a = ["milk", "eggs", "bread"]
shopping_list_b = ["milk", "eggs", "bread"]

print("--- List comparison ---")
print(shopping_list_a == shopping_list_b)  # True  — same CONTENTS
print(shopping_list_a is shopping_list_b)  # False — different OBJECTS in memory

# --- Integers: Python's small integer cache can mislead you ---
small_number_a = 42
small_number_b = 42
print("\n--- Small integer (cached by Python) ---")
print(small_number_a == small_number_b)  # True
print(small_number_a is small_number_b)  # True — only because Python CACHES -5 to 256

big_number_a = 10000
big_number_b = 10000
print("\n--- Large integer (NOT cached) ---")
print(big_number_a == big_number_b)  # True
print(big_number_a is big_number_b)  # False — now they ARE different objects!

# --- The correct pattern: use 'is' ONLY for None checks ---
user_input = None

if user_input is None:          # CORRECT — this is the right use of 'is'
    print("\nNo input received — please enter a value")

if user_input == None:          # WORKS but considered bad style in Python
    print("This also works, but PEP8 prefers 'is None'")

# --- String comparison: never use 'is' for strings you build ---
first_name = "Alice"
search_term = "".join(["Al", "ice"])  # Built at runtime

print("\n--- Runtime string comparison ---")
print(first_name == search_term)  # True  — values match
print(first_name is search_term)  # False — different objects (usually)
Output
--- List comparison ---
True
False
--- Small integer (cached by Python) ---
True
True
--- Large integer (NOT cached) ---
True
False
No input received — please enter a value
This also works, but PEP8 prefers 'is None'
--- Runtime string comparison ---
True
False
💡Pro Tip:
Make this a hard rule: 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.
📊 Production Insight
A caching layer compared strings using is and returned stale data because runtime-generated strings had different identities.
The small integer cache masked the bug during development.
Rule: never use is for value comparison — it's not just style, it's correctness.
🎯 Key Takeaway
== checks value equality; is checks object identity.
Small integers and short strings are cached and accidentally pass is.
Use is only for None, True, False — always use == for everything else.
common-python-mistakes-every-developer-should-know Mutable Defaults vs None Sentinel Comparing shared state corruption and safe patterns Mutable Default None Sentinel Default Evaluation Evaluated once at definition Evaluated once at definition State Persistence Shared across all calls Fresh list created each call Bug Risk High: unexpected mutations Low: explicit initialization Common Use Case Accumulating results incorrectly Safe default for mutable parameters THECODEFORGE.IO
thecodeforge.io
Common Python Mistakes Every Developer Should Know

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.

modify_list_while_iterating.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# ============================================================
# WRONG — removing items from a list while looping over it
# ============================================================
temperature_readings = [22, -5, 30, -1, 18, -8, 25]
print(f"Original readings: {temperature_readings}")

for reading in temperature_readings:
    if reading < 0:  # Try to remove all negative (faulty) readings
        temperature_readings.remove(reading)  # This shifts the list!

# Notice -1 survived! It slid into the slot where -5 was removed,
# and Python's iterator had already moved past that index.
print(f"After (broken) cleanup: {temperature_readings}")
print("Notice -1 was NOT removed — it slipped through!")


# ============================================================
# CORRECT approach 1 — iterate over a COPY of the list
# ============================================================
temperature_readings = [22, -5, 30, -1, 18, -8, 25]  # Reset

for reading in temperature_readings[:]:   # [:] creates a shallow copy
    if reading < 0:
        temperature_readings.remove(reading)  # Safe — we're iterating the copy

print(f"\nAfter (copy) cleanup: {temperature_readings}")


# ============================================================
# CORRECT approach 2 — list comprehension (cleanest, most Pythonic)
# ============================================================
temperature_readings = [22, -5, 30, -1, 18, -8, 25]  # Reset

# Build a NEW list containing only valid readings
valid_readings = [r for r in temperature_readings if r >= 0]

print(f"Valid readings (list comprehension): {valid_readings}")
# This is the preferred approach — readable, safe, and efficient
Output
Original readings: [22, -5, 30, -1, 18, -8, 25]
After (broken) cleanup: [22, 30, -1, 18, 25]
Notice -1 was NOT removed — it slipped through!
After (copy) cleanup: [22, 30, 18, 25]
Valid readings (list comprehension): [22, 30, 18, 25]
🔥Good to Know:
The same problem exists with dictionaries — never add or remove keys from a dict while iterating over it. You'll get a 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.
📊 Production Insight
A data pipeline filtered sensor readings in-place and lost 30% of valid data because adjacent negatives were skipped.
The bug only appeared with specific data distributions.
Rule: never mutate the collection you're iterating — create a new one instead.
🎯 Key Takeaway
Deleting elements while iterating shifts indices and skips items.
Iterate over a copy my_list[:] or use a list comprehension.
List comprehensions are faster, safer, and more readable.

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 copy.deepcopy() from Python's built-in copy module. Knowing when to use each is a sign of genuine Python understanding.

Performance note: deepcopy() 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.

shallow_vs_deep_copy.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import copy  # Python's built-in module for copying objects

# ============================================================
# MISTAKE 1 — assignment is NOT a copy
# ============================================================
original_scores = [95, 87, 76, 88]
aliased_scores = original_scores  # This is NOT a copy — same object!

aliased_scores.append(100)  # Modifying through the alias...
print(f"Original (should be unchanged): {original_scores}")
# Surprise — original changed too!


# ============================================================
# SHALLOW COPY — fine for flat structures
# ============================================================
original_scores = [95, 87, 76, 88]  # Reset
shallow_copy = original_scores[:]   # Creates a new list...

shallow_copy.append(100)             # This only affects shallow_copy
print(f"\nOriginal after shallow copy append: {original_scores}")
print(f"Shallow copy: {shallow_copy}")


# ============================================================
# WHERE SHALLOW COPY FAILS — nested lists
# ============================================================
classroom_grades = [[90, 85, 88], [72, 78, 80], [95, 91, 93]]
shallow_classroom = classroom_grades[:]  # Shallow copy of outer list

# Modifying a nested list through the copy...
shallow_classroom[0].append(99)  # This changes the SHARED inner list!

print(f"\nOriginal classroom (should be unchanged):")
print(classroom_grades)  # The inner list changed — shallow copy problem!


# ============================================================
# DEEP COPY — full independence at every level
# ============================================================
classroom_grades = [[90, 85, 88], [72, 78, 80], [95, 91, 93]]  # Reset
deep_classroom = copy.deepcopy(classroom_grades)  # Copies everything recursively

deep_classroom[0].append(99)  # Modifying the deep copy's inner list

print(f"\nOriginal after deep copy modification:")
print(classroom_grades)   # Unchanged!
print(f"Deep copy:")
print(deep_classroom)     # Only the copy changed
Output
Original (should be unchanged): [95, 87, 76, 88, 100]
Original after shallow copy append: [95, 87, 76, 88]
Shallow copy: [95, 87, 76, 88, 100]
Original classroom (should be unchanged):
[[90, 85, 88, 99], [72, 78, 80], [95, 91, 93]]
Original after deep copy modification:
[[90, 85, 88], [72, 78, 80], [95, 91, 93]]
Deep copy:
[[90, 85, 88, 99], [72, 78, 80], [95, 91, 93]]
💡Pro Tip:
Use the simplest copy that gets the job done: = 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.
📊 Production Insight
A configuration loader used assignment instead of copy, causing feature flags to leak across microservice instances.
The bug was intermittent because it only appeared when configs were modified after loading.
Rule: always explicitly copy when you intend independent data.
🎯 Key Takeaway
Assignment creates an alias, not a copy.
Shallow copy ([:]) works for flat lists; nested mutables remain shared.
Use 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.

scope_leqb.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# ============================================================
# WRONG — trying to modify a global variable without global
# ============================================================
counter = 0

def increment_wrong():
    # Python sees 'counter =' and marks it as local
    # But we reference it before assignment: counter = counter + 1
    # This raises UnboundLocalError
    try:
        counter = counter + 1
    except UnboundLocalError as e:
        print(f"Error: {e}")

increment_wrong()


# ============================================================
# CORRECT — use 'global' to declare intent
# ============================================================
def increment_correct():
    global counter
    counter = counter + 1

increment_correct()
print(f"Counter after global fix: {counter}")


# ============================================================
# BETTER — avoid globals, use parameters and return values
# ============================================================
def increment_best(counter):
    return counter + 1

counter = 0
counter = increment_best(counter)
print(f"Counter after functional fix: {counter}")


# ============================================================
# ENCLOSING SCOPE (nonlocal) for nested functions
# ============================================================
def outer():
    count = 0
    def inner():
        nonlocal count
        count += 1
        return count
    return inner

counter_fn = outer()
print(f"Enclosing scope test: {counter_fn()}, {counter_fn()}")
Output
Error: local variable 'counter' referenced before assignment
Counter after global fix: 1
Counter after functional fix: 1
Enclosing scope test: 1, 2
🔥Keep in Mind:
Python's scope rule prevents accidental global modification. Always prefer passing values as parameters and returning results. Use global sparingly — it makes code harder to test and reason about.
📊 Production Insight
A background task scheduler used global state to track job IDs and frequently hit UnboundLocalError after a refactor.
The error only occurred in certain code paths, making it tricky to catch in tests.
Rule: avoid global mutation in production code — use dependency injection or return values.
🎯 Key Takeaway
Python's LEGB rule: assignment inside a function creates a local variable.
To modify a global, use 'global var_name'.
Better design: pass state as parameters and return updated values.

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.

class_vs_instance_vars.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# ============================================================
# WRONG — class variable shared across instances
# ============================================================
class ShoppingCart:
    items = []  # This is a class variable, shared by all instances!

    def add(self, item):
        self.items.append(item)  # Mutates the shared class variable

cart1 = ShoppingCart()
cart1.add("apple")
cart2 = ShoppingCart()
cart2.add("banana")

print(f"cart1.items: {cart1.items}")  # ['apple', 'banana'] — includes cart2's item!
print(f"cart2.items: {cart2.items}")  # ['apple', 'banana']
print(f"Same list object? {cart1.items is cart2.items}")  # True


# ============================================================
# CORRECT — instance variable created in __init__
# ============================================================
class ShoppingCartFixed:
    def __init__(self):
        self.items = []  # Each instance gets its own list

    def add(self, item):
        self.items.append(item)

cart_a = ShoppingCartFixed()
cart_a.add("apple")
cart_b = ShoppingCartFixed()
cart_b.add("banana")

print(f"\ncart_a.items: {cart_a.items}")  # ['apple']
print(f"cart_b.items: {cart_b.items}")  # ['banana']
print(f"Same list object? {cart_a.items is cart_b.items}")  # False
Output
cart1.items: ['apple', 'banana']
cart2.items: ['apple', 'banana']
Same list object? True
cart_a.items: ['apple']
cart_b.items: ['banana']
Same list object? False
⚠ Watch Out:
The same trap applies to any mutable class variable: dicts, sets, lists. If you define a mutable default at the class level expecting each instance to have its own copy, you're creating shared state. Always use __init__ for mutable per-instance attributes.
📊 Production Insight
A user profile service used a class variable to store session data, causing user A's data to appear in user B's profile under concurrent access.
The bug only appeared under load when instances were created quickly.
Rule: never put mutable objects in the class body — move them to __init__.
🎯 Key Takeaway
Class variables are shared across all instances.
Mutating a class variable affects every instance.
Define mutable attributes in __init__ to give each instance its own copy.

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.

dangerous_bare_except.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import time

def query_database():
    # Simulate a long-running query
    time.sleep(10)
    return [{"id": 1, "name": "Alice"}]

try:
    results = query_database()
    # Typo: 'result' instead of 'results'
    print(f"Fetched {len(result)} records")
except:
    pass  # Silently swallows NameError
print("Script finished — but did it work?")
Output
Script finished — but did it work?
# No error message. No indication that data is missing.
⚠ Production Trap:
In CI/CD pipelines, bare excepts can swallow critical SystemExit(1) signals, making failed deployments look like successes. Your monitoring dashboard shows green while production is burning.
🎯 Key Takeaway
Never catch what you can't handle. Bare excepts are not defensive programming — they're data corruption in slow motion.

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 calculate(). 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 pip install --upgrade. The fix is trivial and mandatory: import exactly what you need, or use the module namespace explicitly (import module; module.function()). It's more typing. It's infinitely more maintainable. Your future self — awake at 2 AM — will thank you.

wildcard_import_disaster.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# internal_tools.py
def calculate(x):
    return x * 2

# main.py
from internal_tools import *

def calculate(x):
    # Intent: complex business logic
    return x * 100

result = calculate(5)
print(f"Result: {result}")
# Is this 10 or 500? Depends on import order.
Output
Result: 500
# Wait — did internal_tools.calculate get called at all?
# If you moved the import, result changes to 10. Good luck debugging that.
🔥Senior Dev Tip:
Run 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.
🎯 Key Takeaway
Star imports are a namespace pollution guarantee. Import what you use, use what you import.
● Production incidentPOST-MORTEMseverity: high

Cross-User Data Contamination from Mutable Default Argument

Symptom
Users reported seeing other customers' failed transaction IDs in their error logs. The bug only appeared under load when multiple requests hit the function simultaneously.
Assumption
The team assumed that each request would get a fresh empty list for error accumulation because the default looked like [].
Root cause
The function signature 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.
Fix
Changed the default to 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.
Key lesson
  • 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.
Production debug guideSymptom-to-action guide for the most frequent Python traps5 entries
Symptom · 01
Function returns unexpected accumulated data after multiple calls without arguments
Fix
Check the function signature for mutable defaults. Print id(default_argument) inside the function to verify it's the same object across calls.
Symptom · 02
Equality check fails for two strings or integers that look the same
Fix
Determine if you used is instead of ==. Log type(value) and id(value) to verify object identity. Use == for all value comparisons.
Symptom · 03
List has fewer items than expected after removal in a loop
Fix
Print the list inside the loop after each removal. Use for item in my_list[:]: to iterate over a copy, or refactor to a list comprehension.
Symptom · 04
Modifying one list changes another list that was assigned earlier
Fix
Check if you used 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().
Symptom · 05
UnboundLocalError when trying to modify a variable defined outside the function
Fix
Add 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.
★ Quick Debug Cheat Sheet for Python Beginner MistakesImmediate steps to diagnose and fix the most common silent bugs in Python.
Function accumulates data across calls without explicit input
Immediate action
Inspect function signature for `[]` or `{}` as default
Commands
print(f'id of default: {id(errors)}') inside the function
print(func.__defaults__) to see the stored default objects
Fix now
Change default to None and create mutable object inside body
Equality check unexpectedly returns False+
Immediate action
Check if you used `is` instead of `==`
Commands
print(type(a), type(b), id(a), id(b))
print(a == b) # should be True if values match
Fix now
Replace is with == for all value comparisons
List loses items during iteration+
Immediate action
Stop modifying the list you are iterating over
Commands
print('Removing', item, 'index', idx) to track skips
len(my_list) before and after loop
Fix now
Replace loop with list comprehension: [x for x in my_list if condition]
Changing one variable changes another unexpectedly+
Immediate action
Check if it was assignment (`=`) not copy
Commands
print(id(original), id(new)) — if same, it's not a copy
print(type(original[0]) if list else 'flat')
Fix now
Use new = original[:] for flat lists, copy.deepcopy() for nested
ConceptWhat It DoesWhen to Use ItCommon Mistake
== (equality)Compares values of two objectsComparing strings, numbers, lists by contentUsing it to check None — works but use 'is None' instead
is (identity)Checks if two variables point to the same objectChecking for None, True, False onlyUsing 'is' for string or number comparisons — silently unreliable
= (assignment)Makes a second name for the same object — no copyIntentional aliasing / referencesThinking it creates a copy — both names change together
list[:] shallow copyNew list, same inner objects sharedFlat lists of strings, numbers, tuplesUsing it on nested lists — inner objects still shared
copy.deepcopy()Fully independent copy at all levelsNested mutable structures like lists of listsUsing it everywhere — slow and memory-heavy when not needed
Mutable default argDefault list/dict created once at definition timeNever — always use None as default insteadPassing [] or {} as default — all calls share the same object
class variable (mutable)Shared across all instances if defined in class bodyConstants or immutable class-level dataUsing 'items = []' in class body expecting per-instance copies
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
mutable_default_argument.pydef add_to_cart_broken(item, cart=[]):The Mutable Default Argument Trap
equality_vs_identity.pyshopping_list_a = ["milk", "eggs", "bread"]== vs is
modify_list_while_iterating.pytemperature_readings = [22, -5, 30, -1, 18, -8, 25]Modifying a List While Iterating Over It
shallow_vs_deep_copy.pyoriginal_scores = [95, 87, 76, 88]Misunderstanding How Python Copies Objects
scope_leqb.pycounter = 0Variable Scope and the LEGB Rule
class_vs_instance_vars.pyclass ShoppingCart:Using Class Variables When You Meant Instance Variables
dangerous_bare_except.pydef query_database():The Bare Except Trap
wildcard_import_disaster.pydef calculate(x):The 'Lazy' Import That Breaks Code at 2 AM

Key takeaways

1
Mutable defaults (lists, dicts) in function signatures are created ONCE at definition time
use None as your default and create the object inside the function body to avoid shared-state bugs.
2
== checks if two values are the same; is checks if they're the same object in memory. Use is only with None, True, and False
never for comparing strings or numbers.
3
Removing elements from a list while iterating over it skips items silently
iterate over a copy (my_list[:]) or use a list comprehension to build a filtered new list instead.
4
Assignment (=) never copies an object
it just creates a second name pointing to the same thing. For real independence, use [:] for flat lists or copy.deepcopy() for nested mutable structures.
5
Python's LEGB scope rule means assignment inside a function creates a local variable. To modify a global, you must declare global var_name; prefer passing state as parameters and returning updated values.
6
Class variables defined with mutable objects are shared across all instances. Always initialize mutable per-instance data in __init__.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is a mutable default argument in Python, and why is it considered d...
Q02JUNIOR
What's the difference between `==` and `is` in Python? Can you give me a...
Q03JUNIOR
If I do `list_b = list_a` and then append to `list_b`, does `list_a` cha...
Q04SENIOR
Explain Python's LEGB rule for variable scope. What happens when you ass...
Q05SENIOR
What is the difference between a class variable and an instance variable...
Q01 of 05JUNIOR

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?

ANSWER
A mutable default argument is a list, dict, or set used as a default value in a function signature, like 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 = [].
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why does Python allow mutable default arguments if they cause bugs?
02
Is it ever correct to use `is` for comparing strings in Python?
03
What's the simplest way to know whether to use shallow or deep copy?
04
Why do I get UnboundLocalError even though a global variable exists with the same name?
05
Can I use a tuple as a default argument safely?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.

Follow
Verified
production tested
July 15, 2026
last updated
2,406
articles · all by Naren
🔥

That's Advanced Python. Mark it forged?

7 min read · try the examples if you haven't

Previous
Pydantic v2: Rust-Powered Data Validation
36 / 36 · Advanced Python