Common Gotchas & Anti-patterns Interview Questions & Answers
Python interview questions on common gotchas: mutable default arguments, late-binding closures, modifying a list while iterating, is vs ==, bare except, mutable class attributes, and shadowing builtins.
A default argument is evaluated once when the function is defined, not on each
call. So a mutable default like [] or {} is created a single time and shared
across every call that doesn't override it — state leaks between calls.
def append_to(item, target=[]): # the SAME list every call
target.append(item)
return target
append_to(1) # [1]
append_to(2) # [1, 2] <- surprise!
def append_to(item, target=None): # the fix: None sentinel
if target is None:
target = [] # fresh list per call
target.append(item)
return target
Use None as the default and create the real object inside the body. This is the
single most famous Python footgun.
Closures in Python capture variables by reference, not by value. When you create functions in a loop, they all close over the same loop variable, which holds its final value by the time they're called.
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2] — not [0, 1, 2]!
# fix: bind the current value via a default argument
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs] # [0, 1, 2]
The default-argument trick captures i's value at definition time. (functools. partial works too.) Remember: closures see the variable's latest value, not a
snapshot.
Changing a list's size during iteration shifts the internal index, causing
elements to be skipped or repeated. With dicts and sets it's worse — Python
raises RuntimeError: dictionary changed size during iteration.
nums = [1, 2, 3, 4]
for n in nums:
if n % 2 == 0:
nums.remove(n) # skips elements — buggy
print(nums) # [1, 3] for some inputs, wrong for others
nums = [n for n in nums if n % 2] # build a new list instead
Fix it by iterating over a copy (for n in nums[:]) or, better, building a new
collection with a comprehension or filter. Never mutate a container's size while
looping over it.
is tests identity (same object), while == tests value. CPython caches
small integers (−5 to 256) and interns some strings, so is coincidentally
returns True for those — but fails for values outside the cache, making it look
unreliable.
a = 256; b = 256
a is b # True — cached
c = 1000; d = 1000
c is d # often False — not cached
c == d # True — always correct
x = "hi"; y = "hi"
x is y # True (interned) — don't rely on it
Caching is an implementation detail, not a guarantee. Rule: use == for value
comparison; reserve is for singletons (None, True, False).
A bare except: (or except Exception: used carelessly) catches everything —
including KeyboardInterrupt and SystemExit — and swallows the error silently,
hiding bugs and making programs impossible to interrupt or debug.
try:
risky()
except: # catches EVERYTHING, even Ctrl-C
pass # error vanishes — undebuggable
try:
risky()
except ValueError as e: # catch only what you expect
log.error("bad value: %s", e)
raise # or handle it deliberately
Catch the specific exceptions you can actually handle, and avoid pass in an
except (at minimum log it). If you must catch broadly, use except Exception (not
bare) so system-exiting signals still propagate.
A mutable value assigned at class level (outside __init__) is one object
shared by every instance. Mutating it through any instance affects all of them
— usually not what you intend.
class Cart:
items = [] # SHARED across all instances
def add(self, x):
self.items.append(x)
a, b = Cart(), Cart()
a.add("apple")
b.items # ['apple'] — leaked into b!
class Cart:
def __init__(self):
self.items = [] # per-instance — correct
Initialize mutable attributes inside __init__ so each instance gets its own.
Class-level attributes are fine for immutable constants/defaults, but never for
mutable per-instance state.
Naming a variable after a builtin — list, dict, id, str, type, sum —
shadows it in that scope, so the original becomes unusable and you get confusing
errors later when you try to call it.
list = [1, 2, 3] # shadows the built-in list type
other = list((4, 5)) # TypeError: 'list' object is not callable
id = 42 # now id() is gone
id(other) # TypeError: 'int' object is not callable
Pick non-conflicting names: items/values instead of list, mapping instead of
dict, user_id instead of id. Linters flag builtin shadowing — heed the warning
to avoid these baffling bugs.
Floats use binary IEEE 754 representation, and 0.1/0.2/0.3 can't be stored
exactly — tiny rounding errors make the sum slightly off. Comparing floats with ==
is a classic trap.
0.1 + 0.2 # 0.30000000000000004
0.1 + 0.2 == 0.3 # False!
import math
math.isclose(0.1 + 0.2, 0.3) # True — tolerant comparison
round(0.1 + 0.2, 10) == 0.3 # True
Rule of thumb: never compare floats with ==; use math.isclose (or Decimal for
exact decimal arithmetic like money).
For lists, += mutates in place (calls __iadd__/extend), affecting all
references to the same list, while a = a + b creates a new list. This surprises
people who alias a list.
a = [1, 2]
b = a
a += [3] # mutates the shared list
b # [1, 2, 3] — b sees it too!
a = [1, 2]
b = a
a = a + [3] # new list bound to a
b # [1, 2] — unchanged
Rule of thumb: += on a mutable object mutates in place (shared refs see it);
a = a + b rebinds to a fresh object. Know which you want when aliases exist.
dict.fromkeys(keys, []) gives every key the same list object — the default is
evaluated once. Mutating one key's list mutates them all, just like the mutable
default-argument gotcha.
d = dict.fromkeys(["a", "b"], [])
d["a"].append(1)
d # {'a': [1], 'b': [1]} — shared list!
# fix: build per-key fresh values
d = {k: [] for k in ["a", "b"]}
Rule of thumb: never use a mutable default with fromkeys; use a dict comprehension
so each key gets its own object.
if not value: is true for many falsy values — 0, 0.0, "", [], False —
not just None. When you only meant "is it None/missing?", valid zero/empty inputs
get wrongly rejected.
def set_timeout(t=None):
if not t: # BUG: t=0 (no timeout) also triggers this
t = 30
return t
set_timeout(0) # 30 — wanted 0!
# fix:
if t is None:
t = 30
Rule of thumb: use is None to test for "missing"; reserve if not value: for
genuine emptiness checks where 0/""/[] should count.
Strings are immutable, so each += creates a new string and copies all
prior characters — turning a loop into O(n²) work. It's a silent performance trap on
large inputs.
# slow: O(n^2), new string every iteration
s = ""
for chunk in chunks:
s += chunk
# fast: O(n), one allocation
s = "".join(chunks)
Rule of thumb: accumulate pieces in a list and "".join() once — never repeatedly
+= strings in a loop.
b = a binds another name to the same object — it doesn't copy. Mutating through
either name affects both. Use slicing, list(), or copy for a real (shallow) copy.
a = [1, 2, 3]
b = a
b.append(4)
a # [1, 2, 3, 4] — same object!
b = a[:] # or list(a), or a.copy() — shallow copy
b = copy.deepcopy(a) # for nested structures
Rule of thumb: assignment never copies in Python; use a[:]/list(a)/copy() for a
shallow copy and deepcopy for nested data.
Defaults are evaluated once at function-definition time, so a default such as
now=time.time() freezes the value at import and never changes — a subtler form of
the mutable-default trap.
import time
def log(msg, ts=time.time()): # ts fixed at definition!
print(ts, msg)
# fix: compute inside the body
def log(msg, ts=None):
if ts is None:
ts = time.time() # fresh each call
print(ts, msg)
Rule of thumb: any default that should be "current" (timestamp, fresh container,
computed value) must use a None sentinel and be created in the body.
Parentheses group expressions; it's the comma that makes a tuple. So (1) is
just the integer 1, while (1,) is a one-element tuple. Forgetting the trailing
comma is a common bug.
type((1)) # <class 'int'> — just parenthesized
type((1,)) # <class 'tuple'> — the comma makes it
type(1, 2) # SyntaxError? no — but (1, 2) is a tuple
# subtle: a stray comma turns a value into a tuple
x = 5, # (5,) — accidental tuple!
Rule of thumb: tuples are defined by commas, not parentheses — write (x,) for a
single-element tuple and watch for accidental trailing commas.
More Pythonic Idioms interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.