Debugging in Python, part 3: Fixing a "simple" bug
MP 140: How can we take a systematic approach to a "simple" bug?
Note: This post is part of an ongoing series about debugging in Python. The posts in this series will only be available to paid subscribers for the first 6 weeks. After that they will be available to everyone. Thank you to everyone who supports my ongoing work on Mostly Python.
The goal for this series is that when you see a bug, you'll be able to think to yourself:
I've seen this kind of error before! I know how to read the traceback, and I have a good idea how to start fixing it.
That's a lofty goal, and it's not possible to cover all kinds of errors in one series. If the error you're facing is one you haven't seen before, the goal is to have a reliable, systematic approach that you can fall back on. In this post, we'll go through the process of resolving a small bug in a single-file project. Starting with a small bug will let us focus on the most essential parts of error messages, and the overall debugging process.
A note about "simple"
It's a good idea to be careful about the word "simple" in general, but that's especially true when talking about bugs and debugging. A bug that's simple for one person to reason through might be quite difficult for someone else. Here I mean we're looking at a single issue in a single-file project. There's no deeper root cause; once we fix the bug, the issue will have gone away.
A working program
If we're going to practice debugging, we need to start with a file that doesn't have any bugs. Here's a program that simulates a two-player game using dice. Each player rolls once, and whoever gets the higher number wins:
... @dataclass class Die: num_sides: int = 6 def roll(self): return random.randint(1, self.num_sides) # Make one die that both players will share. die = Die() # Simulate some battles between players A and B. num_battles = 10 wins_a, wins_b, ties = 0, 0, 0 for _ in range(num_battles): a_result = die.roll() b_result = die.roll() print(f"\nPlayer A: {a_result}") print(f"Player B: {b_result}") if a_result > b_result: ... # Show a summary. print("\n\nSummary:") ...
The class Die
has one method, roll()
. We make one die
object, which both players take turns rolling. We track the number of wins for Player A and Player B, and the number of ties as well. After 10 battles, we print a summary. (You can see the full program listing here.)
A sample run looks like this:
$ python dice_battle.py Player A: 5 Player B: 4 Player A won! Player A: 6 Player B: 6 Tie! ... Summary: Player A won 6 battles. Player B won 1 battles. There were 3 tied battles. Clearly, player A is better at rolling dice.
Player A won 6 times, player B won once, and they had three ties.
This is a single-file project with some meaningful output. Most importantly for our purposes, there are lots of ways things could go wrong in this file.
Using py-bugger
to introduce bugs
Now that we have a working project, we need a way to introduce a bug to focus on. I've been working on a project called py-bugger
, which lets you introduce specific kinds of bugs in order to practice debugging. It's much more useful than offering a link to a version of the project with a known bug, and it's certainly more helpful than just waiting for a bug to appear.
To practice debugging against dice_battle.py, it's helpful to start with a clean Git status. That way if we accidentally make things worse when debugging, we can always use Git to go back to a working state. Then we can start the debugging process over again. I made a folder called debugging_practice/, with just dice_battle.py and a basic .gitignore file:
$ ls dice_battle.py $ git status On branch main nothing to commit, working tree clean $ git log --pretty=oneline 759aae9 (HEAD -> main) Initial working state.
Now we can make a virtual environment, and install py-bugger
(note that the package name on PyPI is python-bugger
):
$ uv venv .venv $ source .venv/bin/activate (.venv)$ uv pip install python-bugger + python-bugger==0.3.3
And here's how to introduce a bug into the project:
(.venv)$ py-bugger -e IndentationError Added bug. All requested bugs inserted.
With the command shown here, py-bugger
has introduced a bug that will cause an IndentationError
the next time the program runs. It's good to make a commit, so the bug is separate from our debugging work in the commit history. If we get lost trying to fix the bug, we can at least revert back to the state with just the bug:
(.venv)$ git commit -am "Intentionally introduced bug." [main 4124d0a] Intentionally introduced bug. 1 file changed, 1 insertion(+), 1 deletion(-)
Now let's try running the program again:
$ python dice_battle.py File "dice_battle.py", line 25 for _ in range(num_battles): IndentationError: unexpected indent
There's a bug! The file doesn't run, and there's an IndentationError
around line 25. We'll go through the process of debugging this issue, and then you can run py-bugger
as many times as you want to practice debugging this same kind of error.
Note: py-bugger
is a fairly new project and may generate the wrong kind of error. If so, and you started with a clean Git status, you can run git checkout .
to undo the change. Then run py-bugger
again, and it will probably generate the right kind of error. If it won't, please consider opening an issue.
Investigation
This is a small bug in a single-file project on our own computer. Even so, we should treat it like any other bug, and go through relevant parts of the process described in part 2.
How can we recreate the bug?
We can recreate the bug by running the command python dice_battle.py
. There are no CLI options; the same bug should occur every time the program is run.
What was the expected output?
We expected to see 10 mini dice-rolling battles, and a summary of how many times each player won.
What was the actual error message?
The full error message is:
File "dice_battle.py", line 25 for _ in range(num_battles): IndentationError: unexpected indent
There was no further output.
For this bug, that's all the information we should need.
Reading a traceback
The error message is called a traceback. It traces back from the final error that was raised, back through the program's execution. Because there's only one file involved here, the traceback is just three lines.
I like to read tracebacks starting at the last line. With a short traceback it doesn't make much difference. But in longer tracebacks, the files you're working with directly are usually shown near the end of the traceback.
The last line tells us that an IndentationError
was raised. This kind of exception can be generated when code is either not indented enough, or indented too much. The second part of this line, unexpected indent
, indicates that the line in question was indented more than expected.
The middle line is the code that Python couldn't run:
for _ in range(num_battles):
The first line states that this code appears at line 25 in dice_battle.py.
Finding a fix
We know where to look for a likely fix, but the problem isn't always on the exact line called out in the traceback. Let's look at a section of code around this line:
... # Simulate some battles between players A and B. num_battles = 10 wins_a, wins_b, ties = 0, 0, 0 for _ in range(num_battles): a_result = die.roll() b_result = die.roll() ...
This part of the code sets some initial values for the number of battles that will be waged, the number of wins for each player, and the number of ties. It then starts a for
loop that runs once for each battle.
The line that starts the for
loop was accidentally indented along with the body of the loop. The fix is to unindent this line:
... # Simulate some battles between players A and B. num_battles = 10 wins_a, wins_b, ties = 0, 0, 0 for _ in range(num_battles): a_result = die.roll() b_result = die.roll() ...
With this fix, the program runs correctly again.
Conclusions
If some of this was new to you, you may want to practice on your own. Make a copy of the original version of dice_battle.py in a new folder, and then install py-bugger
in a virtual environment. Run py-bugger -e IndentationError
, and try to fix the bugs it creates for you. You can also introduce other kinds of bugs, using the arguments -e ModuleNotFoundError
and -e AttributeError
.
In the next post, we'll look at a bug in a project with more than one file. The same principles will apply, but there will be more information and code to sort through in order to get to the actual bug.