Python Lists — Find the Bug¶
Find and fix the bug in each code snippet. Each exercise has a difficulty level and a hidden solution.
Score Card¶
| # | Difficulty | Bug Topic | Found? | Fixed? |
|---|---|---|---|---|
| 1 | Easy | Mutable default argument | [ ] | [ ] |
| 2 | Easy | List aliasing (shared reference) | [ ] | [ ] |
| 3 | Easy | Index out of range | [ ] | [ ] |
| 4 | Medium | Modifying list during iteration | [ ] | [ ] |
| 5 | Medium | Shallow copy vs deep copy | [ ] | [ ] |
| 6 | Medium | List comprehension variable scope leak | [ ] | [ ] |
| 7 | Medium | Sorting returns None | [ ] | [ ] |
| 8 | Hard | is vs == for list comparison | [ ] | [ ] |
| 9 | Hard | Unpacking with * in nested list | [ ] | [ ] |
| 10 | Hard | Thread-unsafe list append | [ ] | [ ] |
Total found: ___ / 10 Total fixed: ___ / 10
Easy (3 Bugs)¶
Bug 1: Mutable Default Argument¶
def add_item(item, items=[]):
"""Add an item to a list and return the list."""
items.append(item)
return items
print(add_item("apple")) # Expected: ["apple"]
print(add_item("banana")) # Expected: ["banana"]
print(add_item("cherry")) # Expected: ["cherry"]
Actual output:
Hint
Default argument values are evaluated once when the function is defined, not each time the function is called. A mutable default like[] is shared across all calls. Solution
**Bug:** The default list `[]` is created once at function definition time. Every call that uses the default shares the same list object, so items accumulate across calls. **Key rule:** Never use mutable objects (`list`, `dict`, `set`) as default argument values. Use `None` and create a new object inside the function body.Bug 2: List Aliasing (Shared Reference)¶
def get_matrix(rows, cols, fill=0):
"""Create a rows x cols matrix filled with a value."""
row = [fill] * cols
matrix = [row] * rows
return matrix
grid = get_matrix(3, 3, 0)
grid[0][0] = 99
print(grid)
# Expected: [[99, 0, 0], [0, 0, 0], [0, 0, 0]]
Actual output:
Hint
[row] * 3 does not copy row three times. It creates three references to the same list object. Solution
**Bug:** `[row] * rows` creates `rows` references to the same inner list. Modifying one row modifies all of them. **Key rule:** Use a list comprehension to create independent inner lists. The `*` operator copies references, not objects.Bug 3: Index Out of Range¶
def get_last_three(items):
"""Return the last 3 elements of a list."""
result = []
for i in range(3):
result.append(items[len(items) - 3 + i])
return result
print(get_last_three([10, 20, 30, 40, 50])) # Expected: [30, 40, 50]
print(get_last_three([1, 2])) # Expected: [1, 2]
Actual output:
Hint
When the list has fewer than 3 elements,len(items) - 3 becomes negative, and adding i may still produce a valid negative index on some iterations but an invalid one on others. Use Python's negative slicing instead. Solution
**Bug:** The function assumes the list has at least 3 elements. When `items = [1, 2]`, `len(items) - 3 + i` starts at `-1`, which works, but the logic breaks for edge cases and is fragile. **Key rule:** Prefer slicing over manual index arithmetic. Slices never raise `IndexError` — they return shorter lists when the range exceeds the list length.Medium (4 Bugs)¶
Bug 4: Modifying List During Iteration¶
def remove_evens(numbers):
"""Remove all even numbers from the list in-place."""
for num in numbers:
if num % 2 == 0:
numbers.remove(num)
return numbers
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print(remove_evens(data))
# Expected: [1, 3, 5, 7, 9]
Actual output:
Hint
Removing an element from a list while iterating over it shifts the remaining elements. The iterator's internal index advances past the next element, causing it to be skipped.Solution
**Bug:** When you remove an element, all elements after it shift left by one. The `for` loop's internal counter still increments, so it skips the element that moved into the removed element's slot. In this case, after removing `8`, `9` shifts to index 7, the iterator moves to index 8 (now `10`), and `10` is never checked at index 7... actually `10` is checked but the skip pattern causes `10` to be missed on certain input arrangements.# FIX Option 1: Iterate over a copy
def remove_evens(numbers):
"""Remove all even numbers from the list in-place."""
for num in numbers[:]: # FIX: iterate over a shallow copy
if num % 2 == 0:
numbers.remove(num)
return numbers
# FIX Option 2: List comprehension (preferred, more Pythonic)
def remove_evens(numbers):
"""Return a new list with only odd numbers."""
return [num for num in numbers if num % 2 != 0]
# FIX Option 3: Iterate backwards
def remove_evens(numbers):
"""Remove all even numbers from the list in-place."""
for i in range(len(numbers) - 1, -1, -1):
if numbers[i] % 2 == 0:
numbers.pop(i)
return numbers
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print(remove_evens(data)) # [1, 3, 5, 7, 9]
Bug 5: Shallow Copy vs Deep Copy¶
import copy
def duplicate_board(board):
"""Create an independent copy of a game board (2D list)."""
new_board = board.copy() # or board[:]
return new_board
original = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
backup = duplicate_board(original)
# Player makes a move
original[1][1] = 0
print(f"Original: {original[1]}") # Expected: [4, 0, 6]
print(f"Backup: {backup[1]}") # Expected: [4, 5, 6]
Actual output:
Hint
list.copy() and [:] create a shallow copy. The outer list is new, but the inner lists are still the same objects. Solution
**Bug:** `board.copy()` creates a shallow copy — it copies the references to the inner lists, not the inner lists themselves. Both `original[1]` and `backup[1]` point to the same `[4, 5, 6]` list.import copy
def duplicate_board(board):
"""Create an independent copy of a game board (2D list)."""
new_board = copy.deepcopy(board) # FIX: deep copy all nested objects
return new_board
# Alternative FIX using list comprehension (faster for 2D lists):
def duplicate_board(board):
"""Create an independent copy of a game board (2D list)."""
return [row[:] for row in board] # FIX: copy each inner list
original = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
backup = duplicate_board(original)
original[1][1] = 0
print(f"Original: {original[1]}") # [4, 0, 6]
print(f"Backup: {backup[1]}") # [4, 5, 6]
Bug 6: List Comprehension Variable Scope Leak (Python 2 Legacy)¶
# This bug manifests when porting Python 2 code or using exec/eval
# In Python 3, list comprehension variables are scoped, but
# the walrus operator can introduce subtle scope issues:
results = []
total = 0
# Developer tries to accumulate a running total in a comprehension
data = [10, 20, 30, 40, 50]
cumulative = [total := total + x for x in data]
print(f"cumulative: {cumulative}") # Expected: [10, 30, 60, 100, 150]
print(f"total: {total}") # What is total now?
Actual output:
Hint
The walrus operator:= in a list comprehension leaks its value to the enclosing scope. The variable total is modified as a side effect, which can cause subtle bugs if the code later assumes total is still 0. Solution
**Bug:** The walrus operator `:=` assigns to the variable in the enclosing scope, not just inside the comprehension. After the comprehension runs, `total` has been mutated to `150`. This is technically "working" but is a design bug — using comprehensions for side effects leads to hard-to-find issues.import itertools
data = [10, 20, 30, 40, 50]
total = 0 # total stays 0
# FIX: Use itertools.accumulate for running totals
cumulative = list(itertools.accumulate(data))
print(f"cumulative: {cumulative}") # [10, 30, 60, 100, 150]
print(f"total: {total}") # 0 (unchanged)
print(f"final sum: {cumulative[-1]}") # 150
Bug 7: .sort() Returns None¶
def get_top_three(scores):
"""Return the top 3 highest scores."""
top = scores.sort(reverse=True)
return top[:3]
scores = [85, 92, 78, 96, 88, 73, 91]
print(get_top_three(scores))
# Expected: [96, 92, 91]
Actual output:
Hint
list.sort() sorts the list in-place and returns None. It does not return the sorted list. Solution
**Bug:** `list.sort()` sorts in-place and returns `None`. Assigning its return value to `top` means `top = None`, and `None[:3]` raises `TypeError`.# FIX Option 1: Use sorted() which returns a new list
def get_top_three(scores):
"""Return the top 3 highest scores."""
top = sorted(scores, reverse=True) # FIX: sorted() returns a new list
return top[:3]
# FIX Option 2: Sort in-place, then slice
def get_top_three(scores):
"""Return the top 3 highest scores."""
scores_copy = scores[:]
scores_copy.sort(reverse=True) # sort in-place (returns None)
return scores_copy[:3] # slice the sorted list
scores = [85, 92, 78, 96, 88, 73, 91]
print(get_top_three(scores)) # [96, 92, 91]
Hard (3 Bugs)¶
Bug 8: is vs == for List Comparison¶
def are_lists_equal(list_a, list_b):
"""Check if two lists have the same contents."""
if list_a is list_b:
return True
return False
x = [1, 2, 3]
y = [1, 2, 3]
z = x
print(f"x == y content: {are_lists_equal(x, y)}") # Expected: True
print(f"x == z content: {are_lists_equal(x, z)}") # Expected: True
Actual output:
Hint
is checks identity (same object in memory), not equality (same value). Two lists with identical contents are == but not necessarily is. Solution
**Bug:** `is` checks whether two variables point to the **exact same object** in memory. `x` and `y` have the same contents but are different objects, so `x is y` is `False`. Only `x is z` is `True` because `z = x` makes them point to the same object.def are_lists_equal(list_a, list_b):
"""Check if two lists have the same contents."""
if list_a == list_b: # FIX: use == for value comparison
return True
return False
# Even more Pythonic:
def are_lists_equal(list_a, list_b):
"""Check if two lists have the same contents."""
return list_a == list_b # FIX
x = [1, 2, 3]
y = [1, 2, 3]
z = x
print(f"x == y content: {are_lists_equal(x, y)}") # True
print(f"x == z content: {are_lists_equal(x, z)}") # True
# Understanding the difference:
print(f"x is y (identity): {x is y}") # False (different objects)
print(f"x is z (identity): {x is z}") # True (same object)
print(f"x == y (equality): {x == y}") # True (same contents)
Bug 9: Unpacking with * in Nested List Operations¶
def flatten_and_process(nested_list):
"""Flatten a nested list and return (first, middle_items, last)."""
# Step 1: Flatten
flat = []
for sublist in nested_list:
flat.extend(sublist)
# Step 2: Unpack
first, *middle, last = flat
# Step 3: Process middle — double each value
middle *= 2 # Developer thinks this doubles each element
return first, middle, last
data = [[1, 2], [3, 4], [5, 6]]
first, middle, last = flatten_and_process(data)
print(f"First: {first}")
print(f"Middle (doubled): {middle}")
print(f"Last: {last}")
# Expected middle: [4, 6, 8, 10]
Actual output:
Hint
list *= 2 does not double each element. It repeats the entire list. [1, 2] * 2 is [1, 2, 1, 2], not [2, 4]. Solution
**Bug:** `middle *= 2` uses the `*` operator on a list, which **repeats** the list (concatenates it with itself), not multiplies each element. `[2, 3, 4, 5] * 2` becomes `[2, 3, 4, 5, 2, 3, 4, 5]`.def flatten_and_process(nested_list):
"""Flatten a nested list and return (first, middle_items, last)."""
flat = []
for sublist in nested_list:
flat.extend(sublist)
first, *middle, last = flat
# FIX: Use a list comprehension to double each element
middle = [x * 2 for x in middle]
return first, middle, last
data = [[1, 2], [3, 4], [5, 6]]
first, middle, last = flatten_and_process(data)
print(f"First: {first}") # 1
print(f"Middle (doubled): {middle}") # [4, 6, 8, 10]
print(f"Last: {last}") # 6
Bug 10: Thread-Unsafe List Operations¶
import threading
def append_range(shared_list, start, end):
"""Append numbers from start to end into the shared list."""
for i in range(start, end):
shared_list.append(i)
# Simulate some processing
temp = shared_list[-1] # Read the value we just appended
assert temp == i, f"Expected {i}, got {temp}"
shared = []
threads = []
for t in range(4):
thread = threading.Thread(
target=append_range,
args=(shared, t * 250, (t + 1) * 250)
)
threads.append(thread)
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Length: {len(shared)}") # Expected: 1000
print(f"Correct: {len(shared) == 1000}")
# The assertion inside the function may FAIL intermittently
Hint
Whilelist.append() itself is thread-safe in CPython due to the GIL, the sequence of append followed by shared_list[-1] is NOT atomic. Another thread can append between your append and your read of [-1]. Solution
**Bug:** The compound operation of "append then read the last element" is not atomic. Between `shared_list.append(i)` and `temp = shared_list[-1]`, another thread can call `append()`, making `shared_list[-1]` return a different value. The assertion fails intermittently.import threading
def append_range(shared_list, lock, start, end):
"""Append numbers from start to end into the shared list (thread-safe)."""
for i in range(start, end):
with lock: # FIX: protect the compound operation
shared_list.append(i)
temp = shared_list[-1]
assert temp == i, f"Expected {i}, got {temp}"
shared = []
lock = threading.Lock() # FIX: add a lock
threads = []
for t in range(4):
thread = threading.Thread(
target=append_range,
args=(shared, lock, t * 250, (t + 1) * 250)
)
threads.append(thread)
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Length: {len(shared)}") # 1000
print(f"Correct: {len(shared) == 1000}") # True
# Alternative FIX: Use queue.Queue for thread-safe producer/consumer
import queue
q = queue.Queue()
# q.put(item) and q.get() are inherently thread-safe
Bonus Challenges¶
After fixing all 10 bugs, try these:
- Write a test suite that catches each bug automatically using
pytest. - Create a linter rule (or find existing
pylint/flake8rules) that detects bugs 1, 4, and 7. - Explain why
[[] for _ in range(3)]is safe but[[]] * 3is not, usingid()to prove it.