Finding bugs when nothing crashes
A method for wrong answers: expected against actual, reproduce, look inside, fix one thing.
Why it matters
An error message at least tells you where to look. The harder bugs give no message at all: the program runs, and the answer is wrong. AI code is full of these. A score is off by one, a filter lets the wrong rows through, a prompt is built from the wrong field. A model will happily write such code for you, so the skill that counts is noticing the answer is wrong, and finding out why.
What you'll be able to do
- Describe a bug as what you expected against what actually happened
- Make a bug happen every time, including one that depends on random numbers
- Look inside a running program with labeled prints and the step-through view
- Check the usual suspects: off-by-one ranges, > against >=, and lines in the wrong block
What it covers
- When the answer is just wrong
- Step 1: expected against actual
- Step 2: look inside with labeled prints
- Fix the basket total
- Step 3: the usual suspects
- Step 4: make it happen every time
- Fix the dice
- Worked example: labels in the wrong order
- Change one thing at a time
When the answer is just wrong
A bug is any way a program does something other than what you meant. Some bugs stop the program with an error, and the errors lesson showed you how to read those. This lesson is about the quiet kind: no error, no warning, just a wrong answer.
Here's one. It should give the average of a week of order totals:
orders = [120, 80, 95, 140, 60, 110, 75] total = 0 for amount in orders: total = total + amount average = total / 6 print("average order:", round(average, 2))
It prints a number, and the number looks believable. That's what makes quiet bugs dangerous. The week has 7 days, but the code divides by 6, so the average comes out too high.
Debugging is a method, not a talent. The method has four steps, and the rest of this lesson takes them one at a time:
- Say exactly what you expected, and what happened instead.
- Make the bug happen every time.
- Look at what the program really does, one line at a time.
- Change one thing, then check again.
Step 1: expected against actual
"It doesn't work" is too vague to fix. "I expected 97.14 and got 113.33" is a lead. To get the expected value, work out a small case by hand, or with a calculator. Seven orders adding up to 680 should average 680 ÷ 7, about 97.14.
Once you know both numbers, the size of the gap is a clue. 113.33 is exactly 680 ÷ 6, which points straight at the division.
Tip: test with tiny, easy inputs first. With orders of 10, 20 and 30, you know the average is 20 without a calculator, so a wrong answer jumps out.
total = 0 for n in range(1, 5): total = total + n print(total)
Show the answer
10
range(1, 5) stops before 5, so it adds 1 + 2 + 3 + 4 = 10. The fix is range(1, 6). This is an off-by-one bug, the most common quiet bug of all.
Step 2: look inside with labeled prints
You can't see variables while a program runs, so make it tell you. A print inside a loop shows each step. The = trick in an f-string prints a variable's name and its value together:
stock = [12, 0, 7, 3] low = [] for count in stock: print(f"{count=}") if count > 0 and count < 5: low.append(count) print(f"{low=}")
Now the program shows its own working. Labeled prints beat bare ones: a column of numbers is easy to misread, while count=0 can't be confused with anything else. Take them out, or comment them out, once the bug is fixed.
The step-through view does the same job without changing your code. It runs the program a line at a time and shows every variable. The next figure has a bug in it. See if you can catch the line where it goes wrong.
The basket should cost 20. Step through and watch total: it's set back to 0 at the start of every pass, because that line is inside the loop. Moving total = 0 above the loop fixes it.
Fix the basket total
This code should set total to the price of everything in the basket, 20, but gets 10. Fix it by moving one line.
prices = [4, 6, 10] for p in prices: total = 0 total = total + p print("basket:", total)
Step 3: the usual suspects
Most quiet bugs are one of a handful of slips. Check these first:
- Off by one.
range(1, 5)stops at 4.len(items)is one more than the last index. >against>=. "Orders of 100 or more get free shipping" needs>= 100. With> 100, an order of exactly 100 misses out.- A line in the wrong block. An indent too many puts a line inside a loop or an
if. One too few takes it out. - Text that looks like a number.
"10" < "9"isTrue, because text is compared letter by letter, and "1" comes before "9". Convert withint()before comparing. - The wrong order of checks. In an
if/elifchain, only the first true branch runs. A general test placed before a specific one swallows it.
print("10" < "9", 10 < 9)
Show the answer
True False
Text compares character by character: "1" comes before "9", so "10" < "9" is True. The numbers compare as numbers, so 10 < 9 is False.
Step 4: make it happen every time
A bug that shows up one run in six is maddening. You change something, it seems fixed, and it was luck. Random numbers are the usual reason. This dice game crashes now and then:
import random random.seed(4) faces = ["one", "two", "three", "four", "five", "six"] for turn in range(5): roll = random.randint(1, 6) print(turn, faces[roll])
random.seed(4) makes the "random" numbers come out the same on every run. Now the crash is reliable, and you can study it. The roll of 6 asks for faces[6], but a six-item list stops at index 5. Either roll randint(0, 5), or use random.choice(faces), which can't miss.
Remove the seed once it's fixed, so the game is random again.
Fix the dice
Change the code so every roll gives a face from the list, and it never crashes. The checks run it with many different seeds.
import random faces = ["one", "two", "three", "four", "five", "six"] rolls = [] for turn in range(30): roll = random.randint(1, 6) rolls.append(faces[roll])
Worked example: labels in the wrong order
Shelves are labeled by number. Multiples of 3 say Fizz, multiples of 5 say Buzz, multiples of both say FizzBuzz, and the rest keep their number. The output for 15 is wrong.
def label(n): if n % 3 == 0: return "Fizz" elif n % 5 == 0: return "Buzz" elif n % 15 == 0: return "FizzBuzz" return str(n) for n in [3, 5, 15, 7]: print(n, label(n))
Expected: 15 FizzBuzz. Actual: 15 Fizz. Now play computer for 15. Is 15 % 3 equal to 0? Yes, so the first branch returns "Fizz" and the function ends. The FizzBuzz branch can never run, because every multiple of 15 is also a multiple of 3. The fix is to move the most specific test first: check n % 15 == 0 before the other two. Change that one thing, then run all four numbers again to make sure nothing else broke.
Change one thing at a time
When you find a suspect, change only that, and run again. If you change three things at once and the bug goes away, you don't know which change fixed it, or whether another one broke something new.
When a fix works, test a few more inputs, especially the edges: 0, 1, the smallest and largest values, exactly-on-the-line values like 100 for a "100 or more" rule. Bugs live at the edges.
You'll learn a fuller method, with tests that catch bugs automatically, in the software engineering phase. This one is enough for most bugs you'll meet.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
A function gives wrong answers now and then, and it uses random numbers. What should you do first?