Python 5.5Written lesson9 minLesson 5 of 7 in Module 5
Debugging
Find a bug systematically.
Free sample. Everything in this module opens with a free account.Video in production.
Read the lesson: Debugging
01A logic error with no traceback
Say someone makes a small change to the count_words function from the review project. Instead of looping directly over the tokens, they rewrite it to use range and read each token by its index. That seems reasonable.
Here are the project's functions, with that new version of count_words, the five reviews, and every token from those reviews collected into one list:
def tokenise(review):
text = review.strip().lower()
for mark in ".,!?":
text = text.replace(mark, "")
return text.split()
def top_words(counts, n=5):
return sorted(counts.items(), key=lambda pair: pair[1], reverse=True)[:n]
def count_words(tokens, stop_words=frozenset()):
counts = {}
for i in range(1, len(tokens)):
token = tokens[i]
if token not in stop_words:
counts[token] = counts.get(token, 0) + 1
return counts
reviews = [
"Great kettle! Boils fast, and it is quiet.",
"The lid is stiff, but it boils fast. Great value!",
"Toasts evenly. Easy to clean, and the crumb tray is great.",
"Great sound, easy to pair, and the battery lasts all week!",
"Not great. The left side is quiet, and it is not easy to fix?",
]
STOP_WORDS = frozenset({"the", "and", "it", "is", "a", "to", "but", "all", "not"})
tokens = []
for review in reviews:
tokens += tokenise(review)
The code still runs, and there is no error. Ask for the five most common words and you get a result:
top_words(count_words(tokens, STOP_WORDS))
[('great', 4), ('easy', 3), ('boils', 2), ('fast', 2), ('quiet', 2)]
great appears 4 times, easy 3 times, and boils, fast and quiet twice each.
But wait. You already know from the earlier modules that great appears five times in these reviews. And you can check that directly:
tokens.count("great")
5
So something is clearly wrong. The function says 4, but the actual count is 5.
No exception, no traceback
Here is the tricky part. Python did not raise any exception. There is no traceback, no error message, nothing telling you which line caused the problem. The program ran successfully. It just gave the wrong answer.
When code runs but does not behave the way it is supposed to, it has a bugKey termCode that runs, and does something other than what it should.. A bug like this one, where the program runs to the end and produces a wrong result, is a logic error. When an exception happens, you can read the traceback and follow it to the line that failed. But what if nothing fails? How do you figure out which line is producing the wrong result?
You will do two things. First, reproduce the bug using the smallest possible input. Then look at what is happening inside the program while it runs.
02Reproducing a bug with a minimal input
Right now you are processing five reviews, and together they give 54 tokens. That is a lot of words to inspect when you are trying to find one small mistake. So instead of debugging all 54 tokens, make the input smaller.
One review
Start with just one review, the kettle review:
count_words(tokenise("Great kettle! Boils fast, and it is quiet."))
{'kettle': 1, 'boils': 1, 'fast': 1, 'and': 1, 'it': 1, 'is': 1, 'quiet': 1}
This review starts with the word great, but the resulting dictionary starts with kettle. The first great is missing. So the bug is still there, even with just one review.
One word
Make the input even smaller: a list containing only one word. You should get a dictionary with great and a count of 1. Instead:
count_words(["great"])
{}
An empty dictionary. Even with just one token, something is going wrong.
Two words
Now try two words, great and kettle. What do you think count_words(["great", "kettle"]) will return? Think about what happened with the single-word example. Think it through before reading on.
I have a guess. Show meHide the answer
count_words(["great", "kettle"])
{'kettle': 1}
You get kettle with a count of 1, and great is missing again.
The same two words, reversed
What if you reverse the order, with kettle first and great second?
count_words(["kettle", "great"])
{'great': 1}
Now you get great with a count of 1, and kettle is missing. So the problem is not the word great. It is whichever word happens to come first in the list.
That is a really useful clue. You have narrowed the problem down from 54 tokens to two, and you now know that the function consistently skips the first token.
This is what it means to reproduceKey termMake a bug happen on purpose, on an input you can give again. a bug: you find an input that makes the problem happen reliably. Ideally, you keep reducing that input until there is as little code and data as possible to investigate. The result is a minimal reproducible input: the smallest input that still shows the bug. It is much easier to understand what is going wrong with two words than with 54.
03Print debugging
You know the first word is being skipped. But why? Look inside the function.
One of the simplest ways to investigate is to print values while the code runs. This is called print debugging, and developers use it all the time.
Add a print call inside the loop. It uses the f-string form you met in the input and output lesson, {i=}, which prints both the variable's name and its value. Then run the function with three tokens: great, kettle and great.
def count_words(tokens, stop_words=frozenset()):
counts = {}
for i in range(1, len(tokens)):
token = tokens[i]
print(f"{i=} {token=}")
if token not in stop_words:
counts[token] = counts.get(token, 0) + 1
return counts
count_words(["great", "kettle", "great"])
i=1 token='kettle'
i=2 token='great'
{'kettle': 1, 'great': 1}
Look at the first line it prints: i=1 token='kettle'. The first index is 1, not 0. So the loop never even looked at the first word. The next line shows i=2 token='great', and the final counts are kettle with 1 and great with 1.
Now you can see what is happening. The loop starts at index 1, which means the token at index 0 is never processed. That is why the count is wrong.
The limits of print debugging
Print debugging is quick and simple, and sometimes one print call is all you need to find a bug. But it has a few limitations.
First, you have to modify your code to add the print calls, and once you are done, you need to remember to remove them.
Second, you only see the values you decided to print. If you did not think to print a particular variable, you have to change the code and run it again.
Third, if the print call is inside a loop, it runs on every iteration. With thousands of tokens, you could end up with thousands of lines of output, which is not always easy to work with.
So there is a better way to inspect a program while it is running: a debugger.
04Breakpoints and pdb
A debuggerKey termA tool that stops a program so you can look at its values. Python's is pdb; VS Code has one built in. is a tool that lets you control how a program runs. Instead of running the entire program from beginning to end, you can tell Python to pause at a particular line. While it is paused, you can inspect the values of your variables. You can also execute the code one line at a time, which makes it much easier to understand exactly what is happening.
The place where you tell the program to pause is called a breakpointKey termThe line where the debugger stops. breakpoint() in the code sets one; so does a click beside a line number in VS Code.. Python gives you a simple way to create one:
breakpoint()
That is it. When Python reaches that line, it pauses execution and starts a debugger. By default, Python uses its built-in debugger, called pdb.
A breakpoint in the loop
Put breakpoint() inside the loop in count_words, then run a small script that calls count_words with the three tokens great, kettle and great. Here is that script, debug_count.py:
def count_words(tokens, stop_words=frozenset()):
counts = {}
for i in range(1, len(tokens)):
token = tokens[i]
breakpoint()
if token not in stop_words:
counts[token] = counts.get(token, 0) + 1
return counts
print(count_words(["great", "kettle", "great"]))
Run it from the terminal with python debug_count.py. When Python reaches the breakpoint, the program pauses. The terminal shows where it stopped (here the absolute path to the file is shortened to ...), then the line it is about to run, and then a prompt:
> .../debug_count.py(6)count_words()
-> if token not in stop_words:
(Pdb)
That (Pdb) prompt means you are now inside the Python debugger. The program has not finished. It is waiting for you to inspect what is happening or tell it what to do next.
Inspecting variables
At this prompt, you can inspect your variables. Start with i. What do you think its value is? Remember what the print call showed. Think it through before reading on.
I have a guess. Show meHide the answer
It is 1. And token is kettle, exactly what print debugging showed.
To show a value, type p followed by an expression. p stands for print: it tells the debugger to display the value of the expression that follows. Here is the whole session. These are the commands typed at the (Pdb) prompt, in order:
p i, token
p counts
c
p i, token
p counts
ll
c
And this is what the terminal shows. The commands were fed to the debugger from a file rather than typed by hand, so they do not appear after each (Pdb) prompt; each (Pdb) is followed by the output of the next command in the list above:
> .../debug_count.py(6)count_words()
-> if token not in stop_words:
(Pdb) (1, 'kettle')
(Pdb) {}
(Pdb) > .../debug_count.py(6)count_words()
-> if token not in stop_words:
(Pdb) (2, 'great')
(Pdb) {'kettle': 1}
(Pdb) 1 def count_words(tokens, stop_words=frozenset()):
2 counts = {}
3 for i in range(1, len(tokens)):
4 token = tokens[i]
5 breakpoint()
6 -> if token not in stop_words:
7 counts[token] = counts.get(token, 0) + 1
8 return counts
(Pdb) {'kettle': 1, 'great': 1}
p i, token shows (1, 'kettle'). Then p counts shows that counts is an empty dictionary. That makes sense, because the program is paused before counting the first token the loop actually visits.
Continuing to the next stop
Next, c, which is short for continue. Python resumes running until it reaches another breakpoint or finishes. Since the breakpoint is inside the loop, it stops there again on the next iteration.
Now i is 2 and token is great. And p counts shows that kettle has already been counted once. So you can watch the dictionary change as the loop runs.
Listing the code
What if you want to see the code around the line where you are paused? Use ll, which stands for long list. It shows the source code of the current function, with an arrow, ->, pointing at the line where execution is paused.
And now you can see something important. The loop uses range(1, len(tokens)). There is the problem: the range starts at 1, so Python never visits index 0. That is why the first token is always missing. The final c lets the program run to the end, and it prints the wrong result, {'kettle': 1, 'great': 1}.
Other commands
A few other debugger commands are worth knowing:
nstands for next. It executes the next line without stepping inside a function call.sstands for step. It lets you step into a function that the current line calls.qquits the debugger.
So with just a few commands, you can pause a program, inspect variables, and follow what the code is doing.
05The debugger in VS Code
pdb is useful, especially when you are working in the terminal. But there is a more visual way to do the same thing. Since you set up VS Code in the previous lesson, use its built-in debugger.
Setting a breakpoint in the editor
Open the script in VS Code and find the loop inside count_words. Look at the space just to the left of the line number. Click there and a red dot appears. That is a breakpoint. So instead of writing breakpoint() in your code, you can set a breakpoint directly in the editor.
Now start the script using Run and Debug. When Python reaches that line, VS Code pauses the program and highlights the current line.
The variables panel
On the left there is a panel showing your variables, and this is where things get really useful. You can see i, which is currently 1; token, which is kettle; counts, which is still empty; and tokens, containing all three words.
So instead of writing a print call for every variable, you can inspect their values directly. And if you want to see what is inside a list or dictionary, you can expand it in the variables panel.
Stepping through the code
Along the top there are a few debugging controls:
- Step over runs the current line and moves to the next one, without stepping into any function calls. It is similar to the
ncommand inpdb. - Step into lets you go inside a function that is being called, just like the
scommand. - Continue resumes execution until the next breakpoint.
The call stack
There is another panel called the Call Stack. Remember the call stack from the recursion lesson, and the traceback from the previous lesson? This is the same idea. The call stack shows which functions are currently active and how the program got to the line where it is paused. In this example, you can see count_words above the frame that called it. So you can inspect not just the current function, but also the path the program took to reach it.
Continuing
Now press Continue. The debugger stops at the breakpoint again, and this time i is 2. It is the same pattern you found earlier: the loop starts at 1, not 0. And that points you directly to the line you need to fix.
06Fixing the cause, and keeping the minimal input
You know the problem is range(1, len(tokens)), because starting at 1 skips the first token. One possible fix would be to change that 1 to a 0. Then the loop would visit every index, starting from the first one, and that would work.
But here is something worth thinking about. Does this function actually need an index? Not really. All it does is go through the tokens and count them. It does not need to know whether a token is at index 0, 1 or 2. It just needs the token itself.
So instead of using range, go back to the kind of loop you learned in the dictionaries lesson, for token in tokens:. It loops directly over every token in the list. And because you are no longer managing indexes yourself, there is no starting index to get wrong.
def count_words(tokens, stop_words=frozenset()):
counts = {}
for token in tokens:
if token not in stop_words:
counts[token] = counts.get(token, 0) + 1
return counts
Checking the fix
Run the smallest example again, a list containing just great:
count_words(["great"])
{'great': 1}
This time you get the correct result: great with a count of 1. Now the three-token example:
count_words(["great", "kettle", "great"])
{'great': 2, 'kettle': 1}
great with 2 and kettle with 1, exactly what you expected.
The original reviews
The bug is fixed, but make sure it also works with the original reviews. You already know how many times great appears across all five. What should that count be? Think it through before reading on.
I have a guess. Show meHide the answer
top_words(count_words(tokens, STOP_WORDS))
[('great', 5), ('easy', 3), ('boils', 2), ('fast', 2), ('quiet', 2)]
5, and that is exactly what you get. The top five words are back to the correct counts: great 5 times, easy 3 times, and boils, fast and quiet twice each. So you fixed the problem without changing the expected behavior of the function.
Find the cause before changing the code
Notice how this went. You did not keep changing the code until the final output looked right. You first found a small input that reproduced the bug. Then you inspected what the function was doing. Only after understanding the cause did you change the code. That is a much more reliable way to debug.
Keep the minimal input
There is one more thing to do. Remember the smallest example, count_words(["great"])? You know the correct result is a dictionary containing great with a count of 1. That is a useful check to keep, because if someone accidentally introduces the same bug again later, this example will catch it.
In the next lesson, you will turn examples like this into automated tests, so that instead of checking the result by hand every time, Python checks it for you.
07Common debugging mistakes
Two mistakes can make debugging harder than it needs to be.
Leaving debugging prints in the code
Print debugging is useful. But once you have finished investigating, remove any temporary print calls you no longer need. Otherwise your program might keep printing information every time it runs.
A breakpoint on a line that never runs
A debugger can only pause when the program actually reaches the breakpoint. So if you have set a breakpoint and nothing happens, check whether that line is being executed at all. Maybe it is inside a condition that never becomes true. Or maybe the function containing it is never called. Those are the first things to check.
08Recap and challenge: debugging
You started with a function that ran without any errors but gave the wrong word counts. Because there was no exception, there was no traceback to help. So you approached the problem in two steps.
- A logic error produces a wrong result without raising an exception, so there is no traceback pointing at the line.
- First, reproduce the bug with a smaller input. Going from 54 tokens across five reviews to just two tokens showed that the first word was always being skipped.
- Then inspect the values while the program runs. Print debugging showed that the loop index began at 1.
- A debugger lets you set breakpoints, pause execution, inspect variables and step through code. You used Python's built-in
pdbdebugger, and then the visual debugger in VS Code. Both found the same problem: the loop started at index 1 instead of 0. - Once you understood the cause, you fixed it by looping directly over the tokens.
- Finally, you kept the smallest example as a check, so you can catch the same bug if it ever comes back.
Your challenge
Here is a function called average_rating. It is supposed to calculate the average rating from a list. If a rating is not a valid whole number, like the word "five" from the previous lesson, it skips that rating. But there is a bug. With these six ratings, it returns about 3.33, and the correct average is 4.0:
def average_rating(ratings):
total = 0
for rating in ratings:
try:
total += int(rating)
except ValueError:
pass
return total / len(ratings)
print(average_rating(["5", "4", "5", "4", "2", "five"]))
3.3333333333333335
Debug it:
- First, find the smallest list of ratings that still produces the wrong answer.
- Then inspect the values while the function runs. You can use
printcalls,pdbor the VS Code debugger. - Once you have found the problem, fix the line that is causing it.
- Check that the function now returns the correct average.
In this lesson, you could find the bug because you already knew the correct answer: great appears five times, so when the function returned four, you knew something was wrong. But what if you do not already know the answer? How do you check that your code is working correctly, especially as a project gets bigger? That is where automated testing comes in. In the next lesson, you will learn how to write tests that check your code for you, every time you make a change.
Key terms: Debugging
The words this lesson introduces, each in one line. The module’s glossary collects them all, and the AI glossary has every term across the courses.
- breakpoint
- The line where the debugger stops.
breakpoint()in the code sets one; so does a click beside a line number in VS Code. - bug
- Code that runs, and does something other than what it should.
- debugger
- A tool that stops a program so you can look at its values. Python's is
pdb; VS Code has one built in. - reproduce
- Make a bug happen on purpose, on an input you can give again.
Quiz: Debugging
Your first pick on each question is the one that counts. Getting one wrong here is how the lesson sticks.