Python Gotchas

Difficulty: Advanced

Python has several well-known gotchas that trip up even experienced developers. These come up frequently in interviews because they test deep understanding of how the language actually works rather than surface-level syntax knowledge.

The mutable default argument gotcha is perhaps the most famous. When you define a function with a mutable default value like `def f(lst=[])`, that default object is created once at function definition time, not on each call. Every call that uses the default shares the same object, so mutations accumulate across calls. The fix is to use `None` as the default and create the mutable object inside the function body.

Late binding closures are another classic trap. When you create closures in a loop, the variable captured by the closure is looked up at call time, not at definition time. This means all closures end up using the final value of the loop variable. The fix is to bind the value at definition time using a default argument.

The `is` vs `==` distinction catches many beginners. `==` checks value equality (calls `__eq__`), while `is` checks object identity (same memory address). Python caches small integers (typically -5 to 256) and short strings, so `is` may appear to work for these values, but it is unreliable and should never be used for value comparison.

Chained comparisons in Python are syntactic sugar that can be surprising. `1 < 2 < 3` works as expected (it means `1 < 2 and 2 < 3`), but something like `False == False in [False]` evaluates as `(False == False) and (False in [False])` which is True. Understanding how Python chains comparisons prevents confusing bugs.

Code examples

Mutable default argument trap

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

print(append_to(1))
print(append_to(2))
print(append_to(3))

The default list is created once and shared across all calls. Each call mutates the same list, so values accumulate.

Fixed: using None as default

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

print(append_to_fixed(1))
print(append_to_fixed(2))
print(append_to_fixed(3))

Using None as default and creating a new list inside the function ensures each call gets its own fresh list.

Late binding closures in a loop

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

results = [f() for f in funcs]
print("late binding:", results)

# Fix: capture i at definition time
funcs_fixed = []
for i in range(4):
    funcs_fixed.append(lambda i=i: i)

results_fixed = [f() for f in funcs_fixed]
print("fixed:", results_fixed)

Closures capture the variable i by reference, not by value. At call time i is 3 for all. Using a default parameter (i=i) captures the current value.

Integer caching and is vs ==

a = 256
b = 256
print("256 is 256:", a is b)
print("256 == 256:", a == b)

x = 1000
y = 1000
print("1000 == 1000:", x == y)
print("use == for values, is for None")

Python caches integers -5 to 256. For 256, 'is' returns True because both reference the cached object. For 1000, behavior of 'is' is implementation-dependent. Always use == for value comparison.

Key points

Concepts covered

mutable default args, late binding closures, is vs ==, integer caching, chained comparisons