Python Gotchas That Trip Up Experienced Devs
A 4-question reference set on Python pitfalls that look obvious in retrospect: mutable default arguments, is-vs-equals identity, the GIL's actual behavior, and closure late-binding in comprehensions.
By CodeSnatch
January 20, 2026
·
Updated May 20, 2026
378 views
3
4.4 (13)
Default argument values in Python are evaluated once at function definition time, not at each call. What does this break, and what is the idiomatic fix?
Examples
Example 1:
Input: def append_to(item, target=[]): target.append(item); return target
print(append_to(1)) # called first time
print(append_to(2)) # called second time
Output: [1] then [1, 2] -- the default list is shared across calls
Explanation: target=[] creates ONE list at def-time; every call without an explicit target mutates it.Example 2:
Input: def safe_append(item, target=None):
if target is None: target = []
target.append(item); return target
Output: [1] then [2] -- each call gets a fresh list
Explanation: Using None as the sentinel and constructing the default inside the body sidesteps the trap.from typing import Optional
def append_to_broken(item, target=[]):
target.append(item)
return target
def append_to_safe(item, target: Optional[list] = None):
if target is None:
target = []
target.append(item)
return target
def cache_result_broken(key, cache={}):
if key not in cache:
cache[key] = expensive_compute(key)
return cache[key]
def expensive_compute(key):
return key * 2Python's is checks identity, == checks equality. CPython caches small integers and short strings, which makes is deceptively work for primitives. When does this break?
Examples
Example 1:
Input: a = 256; b = 256; print(a is b)
c = 257; d = 257; print(c is d)
Output: True, False
Explanation: CPython pre-interns small ints from -5 to 256; outside that range identity is not guaranteed.Example 2:
Input: s1 = "hello"; s2 = "hello"; print(s1 is s2)
s3 = "hello world"; s4 = "hello world"; print(s3 is s4)
Output: True, then implementation-dependent (typically False in REPL, True in module-load when interning kicks in)
Explanation: String interning is opportunistic; never rely on 'is' for value comparison.def reliable_equality(a, b) -> bool:
return a == b
def identity_check(a, b) -> bool:
return a is b
print(identity_check(256, 256))
print(identity_check(257, 257))
print(identity_check(None, None))
x = [1, 2, 3]
y = [1, 2, 3]
print(reliable_equality(x, y))
print(identity_check(x, y))The Python GIL serializes Python bytecode execution across threads. What workload does threading still help with, and what is the correct alternative for CPU-bound work?
Examples
Example 1:
Input: 100 HTTP requests to different URLs, each blocking on network for ~200ms
Output: ThreadPoolExecutor(max_workers=20) finishes in ~1s; sequential takes ~20s
Explanation: GIL is released during I/O syscalls; threads make sense for I/O-bound work.Example 2:
Input: 4 CPU-bound numpy-light Python computations on a 4-core machine
Output: ThreadPoolExecutor gives ~1x speedup; ProcessPoolExecutor gives ~4x
Explanation: CPU-bound Python code needs separate processes (no GIL contention) to actually parallelize.import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def io_bound(url: str) -> int:
time.sleep(0.2)
return len(url)
def cpu_bound(n: int) -> int:
total = 0
for i in range(n):
total += i * i
return total
def run_threads(items, fn):
with ThreadPoolExecutor(max_workers=10) as ex:
return list(ex.map(fn, items))
def run_processes(items, fn):
with ProcessPoolExecutor(max_workers=4) as ex:
return list(ex.map(fn, items))Late binding in Python closures: what does this loop print, and how do you fix it without changing the loop structure?
Examples
Example 1:
Input: funcs = [lambda: i for i in range(3)]; [f() for f in funcs]
Output: [2, 2, 2] -- all closures share the same i, which is 2 when the comprehension finishes
Explanation: Python closures capture by reference; the lambda body looks up i at call time.Example 2:
Input: funcs = [lambda i=i: i for i in range(3)]; [f() for f in funcs]
Output: [0, 1, 2] -- default argument trick binds i at function-definition time
Explanation: Default args evaluate eagerly at def-time, so each lambda captures its own i.def make_funcs_broken():
return [lambda: i for i in range(3)]
def make_funcs_fixed_default():
return [lambda i=i: i for i in range(3)]
def make_funcs_fixed_factory():
def factory(i):
return lambda: i
return [factory(i) for i in range(3)]
print([f() for f in make_funcs_broken()])
print([f() for f in make_funcs_fixed_default()])
print([f() for f in make_funcs_fixed_factory()])