Skip to content

Python · Pythonic Idioms

Python Common Gotchas Explained — Mutable Defaults, Late Binding, and Other Traps

4 min read Updated 2026-06-19 Share:

Practice Common Gotchas & Anti-patterns interview questions

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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel