Py

Python Gotchas

Mutable defaults, late binding closures, scope traps, and other pitfalls that bite even experienced Python developers.

Python Gotchas

The most common Python pitfalls that come up in interviews and production code. Each one is a real bug pattern — know them before you get caught.


1. Mutable Default Arguments

The trap:

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

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b'] — wait, what?

What happens: Default arguments are evaluated once when the function is defined, not each time it's called. The same list object is shared across all calls.

The fix:

def add_item(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

2. Mutation of Arguments Passed In

The trap:

def remove_duplicates(lst):
    lst.sort()
    # ... deduplicate in place
    return lst

original = [3, 1, 2]
result = remove_duplicates(original)
print(original)  # [1, 2, 3] — original is mutated!

What happens: Lists (and dicts, sets, etc.) are passed by reference. Any mutation inside the function affects the original object.

The fix:

def remove_duplicates(lst):
    lst = sorted(lst)  # creates a new list
    # ... or lst = lst.copy() then mutate
    return lst

3. Late Binding Closures in Loops

The trap:

funcs = []
for i in range(3):
    funcs.append(lambda: i)

print([f() for f in funcs])  # [2, 2, 2] — not [0, 1, 2]!

What happens: The lambda captures the variable i, not its current value. By the time you call the lambdas, the loop has finished and i is 2.

The fix:

funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)  # default argument captures the value

print([f() for f in funcs])  # [0, 1, 2]

4. is vs == (Identity vs Equality)

The trap:

a = 256
b = 256
print(a is b)  # True

a = 257
b = 257
print(a is b)  # False — same value, different objects!

What happens: is checks identity (same object in memory), not equality. CPython caches integers from -5 to 256, so small ints share the same object. Anything outside that range creates new objects.

The fix:

# Always use == for value comparison
print(a == b)  # True

# Only use 'is' for singletons: None, True, False
if x is None:
    ...

5. Shallow vs Deep Copy (The Nested List Trap)

The trap:

# Looks like a 3x3 grid
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)  # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] — all rows changed!

What happens: [[0] * 3] * 3 creates three references to the same inner list. Mutating one row mutates them all.

The fix:

# List comprehension creates distinct inner lists
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid)  # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

For general deep copies:

import copy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)

6. Variable Scope & LEGB Rule

The trap:

x = 10

def foo():
    print(x)  # UnboundLocalError!
    x = 20

foo()

What happens: Python sees x = 20 in the function and treats x as a local variable for the entire function scope. The print(x) happens before the local x is assigned — hence UnboundLocalError.

The fix:

x = 10

def foo():
    # Option 1: use a different name
    local_x = 20
    print(x)  # reads the global

    # Option 2: explicitly declare global (use sparingly)
    # global x
    # x = 20

Python resolves names in LEGB order: Local → Enclosing → Global → Built-in.


7. Dictionary Mutation During Iteration

The trap:

d = {"a": 1, "b": 2, "c": 3}
for key in d:
    if d[key] < 3:
        del d[key]  # RuntimeError: dictionary changed size during iteration

What happens: You cannot add or remove keys from a dict while iterating over it. Python raises a RuntimeError.

The fix:

# Iterate over a copy of the keys
d = {"a": 1, "b": 2, "c": 3}
for key in list(d.keys()):
    if d[key] < 3:
        del d[key]

# Or use a dict comprehension to build a new dict
d = {k: v for k, v in d.items() if v >= 3}

8. Truthiness Traps

The trap:

def process(data=None):
    if not data:
        data = get_default()
    return data

process([])   # [] is falsy — gets replaced!
process(0)    # 0 is falsy — gets replaced!
process("")   # "" is falsy — gets replaced!

What happens: In Python, 0, "", [], {}, set(), None, and False are all falsy. Using if not data catches valid empty values you might want to keep.

The fix:

def process(data=None):
    if data is None:
        data = get_default()
    return data

9. String += in Loops is O(n²)

The trap:

result = ""
for word in words:
    result += word + " "  # O(n²) total — copies the entire string each time

What happens: Strings are immutable. Each += creates a new string and copies all previous content. For n words, that's O(1 + 2 + ... + n) = O(n²) total work.

The fix:

result = " ".join(words)  # O(n) — builds the string once

10. except: Catching Too Much

The trap:

try:
    value = int(user_input)
except:
    print("Invalid input")

What happens: Bare except: catches everything — including KeyboardInterrupt, SystemExit, and MemoryError. Your Ctrl+C won't work, and real errors get silently swallowed.

The fix:

try:
    value = int(user_input)
except ValueError:
    print("Invalid input")
# Or at most: except Exception as e:

Always catch the most specific exception type you expect.