Python common gotchas, explained
Python is friendly, but a handful of behaviours surprise almost everyone at least once. Most stem from a few core facts — when default arguments are evaluated, how closures capture variables, and the difference between identity and equality. Knowing these turns "mysterious bug" into "oh, of course."
The mutable default argument
A default argument is evaluated once, when the function is defined — not on each call. A mutable default (list, dict, set) is therefore shared across every call.
def add(item, bucket=[]): # the list is created ONCE
bucket.append(item)
return bucket
add(1) # [1]
add(2) # [1, 2] ← surprise! same list reused
The fix is the standard None sentinel:
def add(item, bucket=None):
if bucket is None:
bucket = [] # fresh list each call
bucket.append(item)
return bucket
Late-binding closures in loops
Closures capture the variable, not its value at creation time. Build functions in a loop and they all see the loop variable's final value.
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] — not [0, 1, 2]!
Capture the value explicitly with a default argument (evaluated at definition time):
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2]
Modifying a list while iterating it
Mutating a collection during iteration skips elements or raises, because the iterator's index drifts as the list shrinks.
nums = [1, 2, 3, 4]
for n in nums:
if n % 2 == 0:
nums.remove(n) # skips elements — buggy
# Result is [1, 3] only by luck; logic is broken
# Iterate a copy, or build a new list:
nums = [n for n in nums if n % 2 != 0]
The comprehension (or iterating over nums[:]) avoids touching the thing you're walking.
is vs == and integer caching
== compares values; is compares identity (same object). They diverge in ways
that look random because CPython caches small integers (-5..256) and some strings.
a = 256; b = 256
a is b # True — cached, same object
x = 257; y = 257
x is y # often False — different objects, same value
Rule: use == for values, and reserve is for None, True, False, and genuine
identity checks. Relying on is for numbers or strings is a bug waiting for a bigger value.
Shallow vs deep copy
Copying a nested structure shallowly duplicates only the outer container; inner objects are shared.
import copy
original = [[1, 2], [3, 4]]
shallow = original[:] # or list(original) / copy.copy
shallow[0].append(99)
original # [[1, 2, 99], [3, 4]] — inner list was shared!
deep = copy.deepcopy(original) # fully independent
Reach for copy.deepcopy when you need the nested objects to be independent too.
Floating-point and chained surprises
Floats are binary approximations, so exact equality often fails — compare with tolerance.
And += on a tuple member, or and/or returning operands rather than booleans, surprise
the unwary.
0.1 + 0.2 == 0.3 # False
import math
math.isclose(0.1 + 0.2, 0.3) # True
"a" or "b" # 'a' — or returns the first truthy operand, not True
0 or "fallback" # 'fallback'
Recap
The classic Python traps share a few roots. Mutable defaults are created once — use a
None sentinel. Closures bind late — capture loop values with i=i. Don't mutate a
collection while iterating it; build a new one. Use == for values and is only for
None/identity, since small-int and string caching makes is deceptive. Remember shallow
copies share inner objects (use deepcopy), and that floats need math.isclose. Each one
is obvious once you know the underlying rule.