Python Coding Interview Gotchas: The Footguns Nobody Warns You About
- Mutable default arguments are evaluated once at function definition, not per call — use
Noneas sentinel and initialize inside the function. isvs==: CPython's integer cache makesisreturnTruefor small integers, causing silent bugs outside the [-5, 256] range; useisonly forNone.- 2D list aliasing:
[[0]*cols]*rowscopies a reference to the same inner list; use a list comprehension to get independent rows. heapqis min-heap only — negate values to simulate a max-heap; there is no built-in flag or alternative.- Backtracking snapshot bug:
result.append(path)stores a live reference that will be empty when recursion unwinds; useresult.append(path[:])instead. - Python modulo always returns a non-negative result for a positive divisor, unlike Java and C++ which follow the dividend's sign.
sort()returnsNone— assigning it destroys your list reference; usesorted()for a new list orarr.sort()in-place without capturing the return value.
Python is the interview language of choice for most engineers right now. It's fast to write, readable under pressure, and the standard library basically does everything except make you coffee. The problem is it also ships with a collection of behaviors that feel completely wrong the first time you hit them. Under interview pressure, "feels wrong" becomes a silent bug you spend the next ten minutes chasing while your interviewer politely watches.
These aren't obscure trivia. Every single one of these gotchas has derailed a real interview.
The Mutable Default Argument Will Outlive You
This one shows up constantly in interview code. You write a recursive helper, slap a list as a default argument because it's convenient, run it once, and the next call starts with leftover state from the previous call.
def dfs(node, path=[]): path.append(node) # path keeps growing across calls like an unpaid intern
The default value is evaluated once, when the function is defined, not fresh on each call. That [] is the same object every time. Modify it in one call and the next call inherits your mess.
The fix is always the same:
def dfs(node, path=None): if path is None: path = [] path.append(node)
Use None as the sentinel. Python's own programming FAQ documents this as an intentional design decision. It does not feel intentional the first time it bites you. In an interview, just pass the accumulator explicitly rather than using a default at all.
is vs ==: Pick the Wrong One and You Get Lucky Sometimes
is checks identity. == checks equality. Different operators, different results, and the really mean part is that is works for small numbers by accident.
a = 1000 b = 1000 print(a is b) # False (probably) print(a == b) # True (always)
CPython caches integers from -5 to 256. Within that range, a = 5; b = 5; a is b returns True because they're the same object in memory. Outside that range, is is unpredictable and implementation-dependent. Your solution that passed with small test cases silently breaks on larger ones. Classic.
Use == to compare values. Use is only for None checks. if node is None is the one correct use. if node.val is 5 is a bug wearing a disguise.
if x is None beats if x == None. None is a singleton in CPython, so the identity check is semantically precise and slightly faster. The one time is is actually what you want.
The 2D List That Secretly Shares Its Rows
You need a grid for a DP problem. You write the one-liner and move on:
dp = [[0] * cols] * rows
Looks clean. Works fine until you write a value. Then multiple rows change simultaneously and you spend the next five minutes convinced your recurrence is wrong.
dp = [[0] * 3] * 3 dp[0][0] = 1 print(dp) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
* rows copies the reference to the inner list, not the list itself. All three rows point to the same object in memory. Write to one row, you write to all of them. The whole table is one row pretending to be three.
The fix is a list comprehension, which creates a fresh list on each iteration:
dp = [[0] * cols for _ in range(rows)]
This bug is especially mean in DP problems. You initialize the table, don't touch every cell immediately, and the corruption surfaces several steps into your recurrence when you've already convinced yourself the logic is right.
Modulo and Floor Division Work Differently Here
In Java, C++, or Go, modulo returns a result with the same sign as the dividend. Python uses the divisor's sign instead. Most of the time you won't notice. Then you hit one problem with a negative input and spend a while wondering if you broke math.
# Python -13 % 3 # 2 (non-negative, same sign as divisor) 13 % -3 # -2 (negative, same sign as divisor) # Java / C++ / Go: # -13 % 3 → -1 # 13 % -3 → 1
This is consistent with Python's floor division, which always rounds toward negative infinity:
-7 // 2 # -4 (floors down, not toward zero) 7 // -2 # -4 # Java / C++: -7 / 2 = -3 (truncates toward zero)
The Python language reference defines the invariant: x == (x // y) * y + (x % y). This guarantees that n % m for positive m always returns a value in [0, m-1], which is actually more useful for modular arithmetic. Python is right here. It's just different from what your muscle memory expects.
The danger is mental translation from another language. If you practiced in C++ and switched to Python mid-prep, the sign difference will quietly produce wrong answers on edge cases with negative inputs. Most problems constrain inputs to non-negative integers, so this lurks silently until the one problem that doesn't.
sort() Returns None and Will Destroy Your Variable
arr = [3, 1, 2] arr = arr.sort() # arr is now None print(arr[0]) # TypeError: 'NoneType' is not subscriptable
list.sort() sorts in-place and returns None. Assigning the result replaces your list reference with nothing. You then try to index None and get a TypeError that points at the wrong line. Good luck.
Use sorted(arr) when you need a new sorted list, and arr.sort() without assignment when sorting in-place.
arr = [3, 1, 2] arr.sort() # mutates arr, returns None new_arr = sorted(arr) # returns a new sorted list
Both accept a key parameter. Both are stable (Timsort). sorted() works on any iterable. This one is actually documented very clearly in Python's stdlib. People still hit it at least once every prep cycle.
heapq Is Always a Min-Heap. No Flag Will Change That.
Python's heapq module implements a min-heap. The documentation is explicit about this. There is no max_heap=True argument, no heapq.maxheap, nothing. You will look for it. It will not be there.
To simulate a max-heap, push negated values and negate again when you pop.
import heapq max_heap = [] heapq.heappush(max_heap, -5) heapq.heappush(max_heap, -1) heapq.heappush(max_heap, -3) largest = -heapq.heappop(max_heap) # 5
For priority queues where you sort on one field and carry another, negate the priority in position 0 of the tuple:
# (-priority, item) so highest priority pops first heapq.heappush(h, (-priority, item))
The negation works because the min-heap invariant on -val is exactly equivalent to the max-heap invariant on val. Ugly but idiomatic. You get used to it. heapq.nlargest(k, iterable) and heapq.nsmallest(k, iterable) exist for one-shot queries without building a heap. For the full array layout behind it, see the heap data structure guide.
Your Backtracking Solution Silently Returns a List of Empty Lists
This one has ended more interviews than I care to count. It's sneaky because the code looks correct, it runs without errors, and only produces the wrong output at the very end.
result = [] path = [] def backtrack(start): if len(path) == k: result.append(path) # WRONG return for i in range(start, n): path.append(nums[i]) backtrack(i + 1) path.pop()
result.append(path) stores a reference to the same list object you're actively mutating. By the time recursion finishes unwinding, path is empty and every element in result points to the same empty list. You return a list of empty lists. No errors, just wrong.
# Fix: snapshot the list at the moment of appending result.append(path[:])
For nested structures you need copy.deepcopy(). In backtracking problems the path is almost always a flat list of integers or characters, so path[:] is sufficient and faster.
This is the same object-sharing behavior behind the 2D grid aliasing bug. Python doesn't copy objects on assignment or passing. It copies references. Your code was right. Python behaved exactly as documented.
Eight More, Faster
These are real footguns. None of them need three paragraphs.
| Gotcha | What happens | Fix |
|---|---|---|
| Recursion limit | Default is 1000 frames. A 300x300 DFS hits it. | sys.setrecursionlimit(10**6) at the top |
float('inf') vs sys.maxsize | sys.maxsize is a finite integer; arithmetic can overflow semantically in DP | Use float('inf') as sentinel infinity |
zip truncates silently | zip([1,2,3], [1,2]) yields only two pairs, no warning or error | Use itertools.zip_longest if you need all elements |
| Late-binding closures | In [lambda: i for i in range(3)], all lambdas return 2 | Capture with default arg: lambda i=i: i |
dict.keys() is a live view | Modifying a dict during iteration raises RuntimeError | Iterate list(d.keys()) or list(d.items()) |
// on negatives | -7 // 2 is -4, not -3 | Python floors toward negative infinity |
NaN comparisons | float('nan') == float('nan') is False | Use math.isnan() for NaN detection |
range() is lazy | range(n) doesn't allocate n integers | Fine for loops. Call list(range(n)) if you need a list object. |
The recursion limit is the most operationally dangerous item in that table. A grid DFS on a 500x500 board can require 250,000 stack frames. Python's default of 1000 will throw a RecursionError long before you finish, usually at the worst possible moment when your solution was otherwise correct. Add sys.setrecursionlimit(10**6) as a reflex for any graph or tree solution.
Your Code Was Correct. Python Behaved.
Here's what's actually evil about these bugs: most of them don't throw exceptions. The 2D aliasing bug silently corrupts your DP table. The backtracking reference bug returns a list of empty lists with no errors. The mutable default accumulates state across calls and fails only on the second invocation, by which point you've convinced yourself the algorithm is wrong.
The fastest way to catch these under pressure is to narrate your assumptions out loud. If you say "I'm shallow-copying path here because I'll be mutating it later," you've forced yourself to verify it. That narration catches bugs that code review misses.
Practicing that narration in realistic interview conditions, not just grinding problems silently, is what actually moves the needle. SpaceComplexity runs voice-based DSA mock interviews with rubric scoring across communication, problem-solving, and code quality. Explaining your Python assumptions to an AI interviewer that pushes back is about as close to the real thing as you can get without booking a slot on interviewing.io.
Python Coding Interview Gotchas: Quick Reference
- Mutable default arguments persist across calls. Use
Noneas sentinel. ischecks identity,==checks equality. Only useisforNone.[[0]*cols]*rowsaliases all rows to the same list. Use a list comprehension.- Python modulo returns a result with the divisor's sign. Java/C++ uses the dividend's sign.
sort()returnsNone. Usesorted()for a new list, orarr.sort()in-place without capturing the return.heapqis min-heap only. Negate values to simulate max-heap.- Backtracking:
result.append(path[:]), neverresult.append(path). sys.setrecursionlimit(10**6)for large graph inputs.