Debugging practice
Python debugging practice problems with tests and answers
Five runnable Python debugging problems with failing unittest tests: a mutable default, late-binding lambdas, removing while iterating, aliased rows, and round().
12 min read · Updated November 1, 2026
How to work through the Python problems
Each problem is a short Python function with a bug and a unittest test that exposes it. Run the file, read the failure, find the cause, and make the smallest fix that turns the tests green. Answers are in a separate section further down, so you can work without spoilers.
![ManualMode overview of five Python bugs: a tags=[] default that keeps values from the first call, a lambda in a loop that always multiplies by 3, removing from a list while iterating that lets an expired session survive, a [[None] * 3] * 3 board where one move fills a column, and round(2.5) returning 2 instead of 3.](/_next/image?url=%2Fguides%2Fpython-debugging-practice-problems.webp&w=3840&q=75)
- Save a problem as its own file, for example
problem1.py. - Run
python3 problem1.py. You need Python 3;unittestis in the standard library, so there is nothing to install. - Before editing, write one sentence: which input fails, what was expected, and what actually happened.
- Change only the function. Do not edit the tests; they describe the contract.
- Rerun until every test passes, then compare your fix and explanation with the answer.
Try each problem without an AI assistant first. The skill being practiced is reading a failure and tracing it to a cause, which is exactly what gets skipped when a tool proposes the fix. Each problem and each answer was run on November 1, 2026, with Python 3.15: every problem fails as described, and every answer passes its tests.
Problem 1: tags leak between calls
The helper returns a list with one new tag. Called without a list, it must start from an empty one every time.
import unittest
def add_tag(tag, tags=[]):
tags.append(tag)
return tags
class AddTagTest(unittest.TestCase):
def test_starts_from_an_empty_list(self):
self.assertEqual(add_tag("urgent"), ["urgent"])
def test_does_not_leak_between_calls(self):
add_tag("first")
self.assertEqual(add_tag("second"), ["second"])
if __name__ == "__main__":
unittest.main()
Both tests fail, and the failure messages show tags from the other test. Where do those tags live between calls?
Problem 2: every multiplier triples
The factory must return three functions that multiply by 1, 2, and 3.
import unittest
def make_multipliers():
"""Return functions that multiply by 1, 2, and 3."""
return [lambda x: x * factor for factor in range(1, 4)]
class MultipliersTest(unittest.TestCase):
def test_each_function_keeps_its_factor(self):
single, double, triple = make_multipliers()
self.assertEqual(single(10), 10)
self.assertEqual(double(10), 20)
self.assertEqual(triple(10), 30)
if __name__ == "__main__":
unittest.main()
The test fails with 30 != 10: the first function multiplies by 3. When does each lambda look up factor?
Problem 3: an expired session survives
The function must drop every expired session and keep the rest in order.
import unittest
def remove_expired(sessions):
for session in sessions:
if session["expired"]:
sessions.remove(session)
return sessions
class RemoveExpiredTest(unittest.TestCase):
def test_removes_a_single_expired_session(self):
sessions = [{"id": 1, "expired": False}, {"id": 2, "expired": True}]
self.assertEqual([s["id"] for s in remove_expired(sessions)], [1])
def test_removes_consecutive_expired_sessions(self):
sessions = [
{"id": 1, "expired": True},
{"id": 2, "expired": True},
{"id": 3, "expired": False},
]
self.assertEqual([s["id"] for s in remove_expired(sessions)], [3])
if __name__ == "__main__":
unittest.main()
A single expired session is removed correctly. Two expired sessions in a row leave one behind. What happens to the loop position when an item is removed?
Problem 4: one move fills a whole column
The function must return a square board of empty cells. Writing to one cell must change only that cell.
import unittest
def empty_board(size):
return [[None] * size] * size
class EmptyBoardTest(unittest.TestCase):
def test_has_the_requested_size(self):
board = empty_board(3)
self.assertEqual(len(board), 3)
self.assertEqual(len(board[0]), 3)
def test_one_move_changes_one_cell(self):
board = empty_board(3)
board[0][0] = "X"
self.assertEqual(board[1][0], None)
self.assertEqual(board[2][0], None)
if __name__ == "__main__":
unittest.main()
The size test passes. After setting board[0][0], the cell below it is 'X' too. How many distinct row lists does the board contain?
Problem 5: 2.5 stars becomes 2
Review averages are shown as whole stars, and a half always rounds up.
import unittest
def stars(average):
"""Round a review average to whole stars. Halves round up: 2.5 is 3 stars."""
return round(average)
class StarsTest(unittest.TestCase):
def test_rounds_down_below_the_half(self):
self.assertEqual(stars(4.4), 4)
def test_rounds_every_half_up(self):
self.assertEqual(stars(3.5), 4)
self.assertEqual(stars(2.5), 3)
if __name__ == "__main__":
unittest.main()
3.5 rounds to 4 as expected, but 2.5 rounds to 2. What rule does round() follow for an exact half?
Answers and explanations
Problem 1. A default value is evaluated once, when the def statement runs, not on every call. Every call without a list appends to the same list object. Use None as the default and create the list inside the function:
def add_tag(tag, tags=None):
tags = [] if tags is None else list(tags)
tags.append(tag)
return tagsCopying with list(tags) also stops the helper from changing a list a caller passes in, which the contract implies but the tests do not check.
Problem 2. A lambda looks up factor when it is called, not when it is created. By then the loop has finished and factor is 3, so all three functions multiply by 3. Bind the current value as a default argument, which is evaluated when each lambda is created:
def make_multipliers():
"""Return functions that multiply by 1, 2, and 3."""
return [lambda x, factor=factor: x * factor for factor in range(1, 4)]Problem 3. The loop walks the list by position. Removing item 1 shifts item 2 into position 0, and the loop moves on to position 1, so item 2 is never checked. Build a new list instead of removing items from the one you are iterating over:
def remove_expired(sessions):
return [session for session in sessions if not session["expired"]]The fixed version returns a new list and leaves the input unchanged. If callers relied on in-place removal, assign the result back with sessions[:] = ....
Problem 4. Multiplying a list of lists repeats references, not copies. The board holds the same row list three times, so a write through any row appears in all of them. Create a new row for each index:
def empty_board(size):
return [[None] * size for _ in range(size)]Problem 5. Python's round() rounds an exact half to the nearest even number: round(0.5) is 0, round(2.5) is 2, and round(3.5) is 4. For half-up rounding, use decimal with ROUND_HALF_UP. Converting through str() keeps the decimal value you see, rather than the float's exact binary value:
from decimal import Decimal, ROUND_HALF_UP
def stars(average):
"""Round a review average to whole stars. Halves round up: 2.5 is 3 stars."""
return int(Decimal(str(average)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))The behavior is documented in the Python docs for function definitions, lambdas defined in a loop, modifying a collection while iterating, multidimensional lists, and round(), checked November 1, 2026.
Why these bugs show up in AI-generated code
All five are locally plausible: each function reads well, and a quick demo with one call and typical inputs works. The failures appear only on a second call, a second consecutive match, a second row, or an exact half. Generated code often passes the obvious case and misses exactly those.
When you review a generated Python patch, look for the same patterns: mutable default arguments, lambdas or closures created in loops, remove or del inside a loop over the same list, [[...]] * n, and round() on values shown to users or used for money. The AI code review checklist turns these into review steps, and mutation testing checks that your tests would catch them.
Keep practicing
Want more? The JavaScript debugging practice problems use the same format with five different bugs.
ManualMode's free code review exercise asks you to approve or reject two synthetic AI-generated patches, and you can choose Python. It takes about five minutes, with no account or code upload. After signup, three Gym reps and one Project rep from your own repository are free, with no card required.
Start with evidence
Calibrate with three Gym reps, then verify one real Project task.
3 Gym + 1 Project reps free. Create an account; no card or public review required.