Python Gotchas That Fail Interviews
Eight snippets that look obvious and aren’t — mutable default arguments, late-binding closures, is vs ==, chained comparisons, mutation during iteration, shallow copies, shared class attributes, and a finally that eats a return. Predict each output, then meet the one evaluation-model rule underneath. Every result run in real CPython, every claim traced to the docs.
Concept · Languages & Runtimes. The source ↗
A free, interactive, animated visual explainer of Python Gotchas That Fail Interviews — built to be understood, not skimmed.
Questions
- Why is a mutable default argument dangerous in Python?
- Because the default value is created once, when the function is defined, not on each call — so a mutable default like target=[] is a single list shared by every call that omits the argument. The Python tutorial warns: "The default value is evaluated only once. This makes a difference when the default is a mutable object such as a list, dictionary, or instances of most classes." append_to(1) then append_to(2) returns [1, 2], not [2], because both calls append to the same list. The idiom that fixes it is a None sentinel: def f(target=None): if target is None: target = [].
- Why do all my lambdas in a loop return the same value?
- Late binding. [lambda: i for i in range(3)] builds three functions that all print 2, not 0, 1, 2. The Programming FAQ explains it exactly: "This happens because x is not local to the lambdas, but is defined in the outer scope, and it is accessed when the lambda is called — not when it is defined." By the time you call them the loop has finished with i == 2. Capture the value per-iteration with a default argument instead: [lambda i=i: i for i in range(3)] returns 0, 1, 2.
- What is the difference between == and is in Python?
- == asks whether two objects have equal value; is asks whether they are the same object. The language reference: "The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object." They come apart because of caching: CPython pre-allocates the small integers −5 through 256, so 256 is 256 is True but 257 is 257 can be False — the second 257 is a fresh object. This is a CPython implementation detail, so never use is to compare numbers or strings; use == for value and reserve is for None, True, and False.
- Why does mutating a list or dict while iterating over it misbehave?
- Because the loop is walking the live object, not a snapshot. For a dict, adding or removing a key mid-loop raises RuntimeError: "dictionary changed size during iteration" — the docs state "Iterating views while adding or deleting entries in the dictionary may raise a RuntimeError or fail to iterate over all entries." For a list, deleting the current element shifts every later index down by one, so the iterator skips the next element silently. Iterate over a copy (for k in list(d)) or build a new collection instead of editing the one you are looping.
- Why do the rows of [[0]] * 3 all change together?
- Because * copies references, not objects. [[0]] * 3 is a list of three references to the same inner list, so grid[0].append(9) makes grid become [[0, 9], [0, 9], [0, 9]]. The FAQ is blunt: "The reason is that replicating a list with * doesn't create copies, it only creates references to the existing objects." It is the same reason a shallow copy surprises you — the copy module notes a shallow copy "inserts references into it to the objects found in the original." Build independent rows with a comprehension: [[0] for _ in range(3)].