Skip to content

Common Gotchas & Anti-patterns Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

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.

Read the in-depth guidePython Common Gotchas Explained — Mutable Defaults, Late Binding, and Other Traps(opens in new tab)
15 of 15

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 ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel