Common Python bugs and gotchas — a field reference
Every bug in this lesson has already been explained mechanically somewhere earlier in this domain — this is the field-reference version, the shape each one actually takes in real code, so it's recognizable on sight instead of requiring the mechanism to be re-derived from scratch every time.
4 min read
Bug 1: the mutable default argument, shared across every call
def add_item(item, cart=[]): # cart is created ONCE, at function-definition time
cart.append(item)
return cart
add_item("apple") # ["apple"]
add_item("banana") # ["apple", "banana"] — NOT a fresh list — the SAME list from beforecart=[] creates exactly one list object, at the moment the function is defined, and every call that doesn't pass its own cart shares that same object — appending in one call leaves the change visible in the next. The fix: cart=None, then if cart is None: cart = [] inside the function body, creating a genuinely fresh list on every call that needs a default.
Bug 2: a closure in a loop capturing the loop variable by reference, not value
functions = [lambda: i for i in range(3)]
[f() for f in functions] # [2, 2, 2] — NOT [0, 1, 2]Every lambda closes over the variable i, not its value at the time the lambda was created — by the time any of them actually run, the loop has finished and i is 2, so all three return the same final value. The fix: lambda i=i: i, using a default-argument value (evaluated immediately, at definition time) to capture the current value instead of the variable itself.
Bug 3: a shallow copy that still shares nested mutable objects
import copy
original = {"items": [1, 2, 3]}
shallow = copy.copy(original) # or original.copy(), or dict(original) — all equally shallow
shallow["items"].append(4)
print(original["items"]) # [1, 2, 3, 4] — the ORIGINAL changed too, unexpectedlyA shallow copy creates a new top-level container, but the values inside it — if they're themselves mutable objects like a list or dict — are the exact same objects as in the original, not independent copies; mutating a nested list through the copy mutates the shared object the original also references. The fix: copy.deepcopy(), which recursively copies every nested mutable object too, producing a structure genuinely independent all the way down.
Bug 4: == vs is — comparing value vs identity, confused in both directions
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — same VALUE (equal contents)
a is b # False — DIFFERENT objects in memory, despite equal contents
x = None
x == None # works, but the wrong tool
x is None # the CORRECT, idiomatic check — None is a singleton, identity comparison is exactly right here== calls __eq__ and compares value (does this look the same); is compares identity (is this the literal same object in memory) — using is where == was meant produces False for two separately-constructed but equal objects, and using == for a None check works but isn't idiomatic, since None is guaranteed to be a singleton, making is None both correct and the conventional, expected style. The fix: == for value comparison (the overwhelming majority of real comparisons), is specifically for None, True/False, and genuine identity checks.
Bug 5: catching Exception too broadly, hiding a real bug as a "handled" error
try:
result = risky_operation()
except Exception: # catches EVERYTHING — including a real bug like a TypeError from a typo
result = None # silently swallowed — the actual error is now invisibleCovered mechanically in this domain's exception-handling lesson: catching Exception broadly to "handle errors gracefully" also catches genuine programming bugs (a typo causing an AttributeError, a logic error causing a TypeError) that should have crashed loudly during development, not been silently converted into a None result that causes a confusing failure somewhere else entirely. The fix: catch the specific exception type actually expected (except ValueError:, except KeyError:), letting anything unexpected propagate and surface as a real, visible error.
The actual throughline across all five
Every one of these traces back to the same handful of mechanisms this domain already covered in depth: mutable objects being shared by reference rather than copied implicitly, closures capturing variables (not value snapshots), and the real, meaningful difference between comparing value and comparing identity. Recognizing a bug's shape on sight — "this smells like a mutable default," "this smells like a shallow-copy sharing problem" — is what separates fixing a Python bug quickly from re-deriving these mechanisms from first principles every single time one shows up.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does `def add_item(item, cart=[])` cause the same list to be shared across every call that doesn't pass its own cart?
2. Why does `[lambda: i for i in range(3)]` produce three functions that all return 2, instead of 0, 1, and 2?
3. Why does mutating a nested list inside a shallow copy also change the original?