Finding mistakes, designing tests, building programs incrementally, and writing code that communicates — the habits that turn programs that seem to work into programs that actually do
"Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?"
Brian Kernighan, computer scientistPrograms rarely work correctly the first time. This is not a sign that you are doing something wrong — it is the normal, expected, universal experience of every programmer who has ever lived. From beginners writing their first ten-line program to expert engineers building systems with millions of lines of code, mistakes happen. The question is not how to avoid them entirely, but how to find them, understand them, and fix them.
This chapter teaches you to do exactly that. You will learn the three major categories of programming errors, how to read the messages a programming environment produces when something goes wrong, and a set of reliable strategies for finding mistakes when error messages are not enough. You will also learn how to test programs deliberately and thoroughly — not just running them once and hoping, but designing specific tests that check whether your code handles all the situations it will encounter. Finally, you will learn about program design: how to think about structure and organization before and during the writing of code, so that programs are easier to read, easier to test, and easier to fix.
These skills — debugging, testing, and design — are what separate programs that merely seem to work from programs that actually do.
You write a program that calculates student averages. You run it, it produces numbers, and the numbers look reasonable. Is the program correct? How would you know? You would need to check specific inputs against known expected outputs, test what happens with edge cases like an empty list or a list with a single score, and verify that the logic handles every boundary correctly. By the end of this chapter, you will have a systematic method for answering that question — for any program you write.
Before learning how to fix mistakes, it helps to understand what mistakes actually are and why they happen so consistently. A program is a precise set of instructions. Computers follow those instructions exactly as written — not as intended, not as assumed, but exactly as written. This creates an immediate source of tension: human beings are imprecise thinkers who work with intentions, and computers are precise executors that work with instructions. The gap between intention and instruction is where almost every programming mistake lives.
| Source | Description | Example |
|---|---|---|
| Complexity | Many interacting parts — variables, loops, functions, conditions — each a potential failure point | A variable updated in one function has unexpected effects in another |
| Invisible assumptions | Unstated beliefs about the program's environment that live in the programmer's head, not the code | Assuming a list will never be empty; assuming user input will be a number |
| Translation difficulty | Moving from a human description to precise code requires many small decisions, any of which can be wrong | "Calculate the average" requires deciding how to handle zero count, rounding, and input format |
| Accumulation | A small mistake early in a program can produce effects only visible much later | Wrong initialization on line 5 causes a crash on line 40 |
Experienced programmers make mistakes constantly. What distinguishes them is not that they make fewer mistakes — it is that they have good strategies for finding and fixing them efficiently. Debugging is a skill. When something goes wrong, a program is telling you something. Your job is to figure out what it is telling you.
Every programming language has a set of rules called its syntax — the grammar of the language. A syntax error occurs when code violates these rules. Syntax errors are caught before the program runs: Python reads the code, detects the violation, reports it, and refuses to execute anything. You cannot have a running program with a syntax error in it.
This makes syntax errors the easiest category to deal with. They are caught immediately, they tell you roughly where the problem is, and fixing them is usually a matter of correcting a typo or misplaced character.
if score > 90
print("Excellent")
if score > 90:
print("Excellent")
result = (3 + 4 * 2
result = (3 + 4 * 2)
if temperature > 100: print("Very hot")
if temperature > 100: print("Very hot")
| Syntax Error Type | What to Look For |
|---|---|
| Missing colon | End of if, elif, else, for, while, def |
| Mismatched delimiters | Count opening and closing (), [], {} |
| Wrong indentation | Consistent four-space indent inside every block |
| Misspelled keyword | True not true; elif not elseif; def not Def |
| Unclosed string | Every " or ' must have a matching closing quote |
When fixing a syntax error, read slowly. Many syntax errors are a single missing or wrong character. Train your eye to look for what is absent, not just what is present. Also note: the line Python reports is where it noticed the error — check a few lines before that line if the reported line looks correct.
A runtime error occurs while a program is running, when a syntactically correct operation cannot be completed. The program starts, runs until it reaches an impossible situation, then crashes and produces an error message called an exception. Runtime errors depend on conditions: the same line of code might run successfully one hundred times and fail on the hundred-and-first, when conditions happen to be different.
average = total / countcount is 0. If count is 5, the line runs fine. If count is 0, Python raises ZeroDivisionError and stops.| Error Type | Meaning | Common Cause |
|---|---|---|
ZeroDivisionError | A number was divided by zero | Unguarded division where denominator can be 0 |
IndexError | A list or string position does not exist | scores[3] when list has only 3 items (indices 0–2) |
NameError | A variable or function has not been defined | Using a variable before assigning it a value |
TypeError | An operation was used on the wrong type | "Score: " + 85 — cannot add string and integer |
FileNotFoundError | A file at the given path does not exist | Typo in filename, file in wrong folder |
KeyError | A dictionary key does not exist | Accessing a key that was never added to the dictionary |
ValueError | Right type, wrong content | int("hello") — cannot convert a word to an integer |
When you encounter a runtime error, do not just look at the crashing line. Trace backward. Ask how the values in play got to where they are. A variable might be assigned the wrong value twenty lines earlier, causing a crash only when that value is finally used.
A logic error is a mistake in the reasoning of a program. The program has correct syntax. It runs without crashing. It produces output. But the output is wrong. Logic errors do not produce error messages. No alarm sounds. No exception is raised. The program runs to completion and silently delivers wrong results — this is what makes them the hardest category to find.
for i in range(10): print(i)
The code is legal. Python produces no error. But the intent was range(1, 11). Off-by-one errors are among the most common logic errors in all of programming.
if score > 60: print("Pass")
60 > 60 is False. The boundary should be >=. The program runs and produces plausible output. The bug is invisible until you test the exact boundary value.
total = 0 count = 0 for value in scores: total = total + value count = count + 1 average = total / total # Should be: total / count
largest = scores[0] for score in scores: if score > largest: pass # Forgot: largest = score print("Largest:", largest)
Logic errors are hard because the programmer's mental model is wrong. When you wrote the code, you believed it was correct. That belief makes the error invisible: you look at the code and see what you intended, not what is actually written. This is why testing matters so much — running the program against known expected outputs reveals logic errors that careful reading might miss entirely.
Many beginning programmers look at error messages with dread and skip over them. This is a mistake. Error messages contain real, useful information about what went wrong. Learning to read them efficiently is one of the most practical skills in debugging.
Traceback (most recent call last): File "grades.py", line 14, in calculate_average average = total / count ZeroDivisionError: division by zero
| Part of Message | What It Tells You |
|---|---|
Traceback (most recent call last): | Python is about to show the sequence of function calls that led to the error |
File "grades.py", line 14 | The file name and line number where the crash occurred |
average = total / count | The actual line of code being executed when the error occurred |
ZeroDivisionError: division by zero | The error category and a plain-language description of the problem |
For longer tracebacks with multiple function calls, read from the bottom up. The actual error is always at the bottom. The lines above it show the chain of function calls that led there. Start your investigation at the crash point (bottom), then trace upward only if you need to understand how the program arrived there.
Reading error messages is the first step. But not every bug produces a helpful error message — logic errors produce none at all — and even with an error message, you may not immediately understand what went wrong. The following strategies form a reliable toolkit for finding and fixing bugs at every level of complexity.
The simplest debugging strategy is also the most frequently skipped: read the code. Read it slowly, line by line, as if you have never seen it before. Do not read what you intended to write. Read what is actually written. A surprisingly high percentage of bugs can be found this way. Look for wrong variable names (especially when two variables have similar names), wrong operators (> versus >=, and versus or), missing updates inside loops, reversed conditions, and correct actions placed in the wrong location.
Pick up a pencil and paper. Work through the program manually, step by step, exactly as the computer would. Keep a table tracking the value of every important variable at every step. This technique — called hand tracing or desk checking — is powerful because it forces you to follow the actual logic of the code rather than the logic you believe the code has.
scores = [3, 7, 2, 9, 4] largest = 0 # The bug is here for score in scores: if score > largest: largest = score print("Largest:", largest)
| Step | score | largest | score > largest | Update? |
|---|---|---|---|---|
| Start | — | 0 | — | — |
| 1 | 3 | 0 | True | largest = 3 |
| 2 | 7 | 3 | True | largest = 7 |
| 3 | 2 | 7 | False | no change |
| 4 | 9 | 7 | True | largest = 9 |
| End | — | 9 | — | Output: 9 ✓ |
Now try with scores = [-5, -3, -8, -1]:
| Step | score | largest | score > largest | Update? |
|---|---|---|---|---|
| Start | — | 0 | — | — |
| 1 | -5 | 0 | False | no change |
| 2 | -3 | 0 | False | no change |
| 3 | -8 | 0 | False | no change |
| 4 | -1 | 0 | False | no change |
| End | — | 0 | — | Output: 0 ✗ (should be -1) |
largest = 0 assumes all values will be positive. The fix: initialize to the first element of the list — largest = scores[0]. This is a logic error that hand tracing reveals clearly. No error message would ever catch it.One of the most practical debugging tools available at any level is the simple print() function. Add print statements at key points to display the values of variables as the program runs. This makes the computation completely visible. When the bug is fixed, remove or comment out the debugging print statements.
def calculate_average(scores): total = 0 count = 0 for score in scores: total = total + score count = count + 1 print(f" After adding {score}: total={total}, count={count}") print(f"Computing: {total} / {count}") return total / count
If a bug is hard to find in a complex program, try to reproduce it in a simpler context. Extract just the suspected function, remove everything else, and test it in isolation with a few simple inputs. If the function works correctly alone, the bug is in how it is called or how input is prepared — not inside the function itself.
Most bugs exist because a programmer assumed something that was not true. When stuck, write down the assumptions your code makes and then check each one explicitly.
→ Is the list ever empty?
→ Can any numeric value be zero or negative?
→ Can the user enter unexpected characters or spaces?
→ Does this function always return a value for every possible input?
→ Is this variable always set before it is used?
When you have wrong output but no error message, start at the output and work backward. Ask: what value produced this output? What code set that value? What values did that code depend on? Keep tracing backward until you find the step where something went wrong. This reverse-tracing technique is particularly useful for logic errors.
When you stare at the same code for a long time, your brain fills in what it expects to see rather than what is there. You stop reading the code and start reading your memory of writing the code. Taking a break of fifteen minutes, an hour, or a day — returning fresh — frequently makes previously invisible bugs immediately obvious. If you have been debugging for a long time without success, stopping is often the most efficient path forward.
Debugging is reactive: something goes wrong, and you hunt for the cause. Testing is proactive: before or after writing code, you deliberately run it with specific inputs to verify that it produces correct outputs. A test case is a specific input combined with its expected output. You run the program with the input and compare the actual output to the expected output. If they match, that case is handled correctly. If they do not match, there is a bug.
The key discipline is that test cases should be designed before running the program when possible, not after. Deciding what the correct output should be before you see what the program produces forces you to think clearly about the specification. Once you have already seen the output, it is easy to unconsciously rationalize it as correct.
| # | Input (°C) | Expected Output (°F) | Reasoning |
|---|---|---|---|
| 1 | 0 | 32.0 | Freezing point of water — well-known anchor |
| 2 | 100 | 212.0 | Boiling point of water — well-known anchor |
| 3 | -40 | -40.0 | Only point where Celsius and Fahrenheit are equal |
| 4 | 37 | 98.6 | Normal human body temperature |
| 5 | 20 | 68.0 | Typical room temperature — sanity check |
| # | Input | Expected | Reasoning |
|---|---|---|---|
| 1 | [90] | 90.0 | Single-element list — should return that element |
| 2 | [80, 90] | 85.0 | Two elements, clean average |
| 3 | [70, 80, 90] | 80.0 | Three elements, symmetric around middle |
| 4 | [100, 100, 100] | 100.0 | All same values |
| 5 | [0, 0, 0] | 0.0 | All zeros |
| 6 | [75, 82, 91, 68, 95] | 82.2 | Typical realistic case |
Running a program once and seeing plausible-looking output is not testing — it is hoping. Real testing means deliberately choosing inputs that cover different scenarios and verifying outputs against carefully determined correct values known in advance. One successful run proves nothing about edge cases or boundary conditions.
Typical test cases verify that a program handles normal inputs correctly. Edge cases test the boundaries, extremes, and special situations that a program might encounter — the cases most likely to reveal hidden bugs and violated assumptions.
Most programs are written with typical inputs in mind. The code that handles typical inputs is usually correct because it is the code the programmer thought about most carefully. Edge cases are the inputs the programmer did not think carefully about — and they often expose assumptions that were wrong.
| Edge Case Category | Description | Example |
|---|---|---|
| Empty input | Empty list, string, or file | scores = [] passed to find_largest() |
| Single element | Collection with exactly one item | calculate_average([88]) |
| Boundary value | Exactly at the limit of a condition | Score of exactly 60 in a pass/fail function |
| Negative values | Values below zero | List of all negative numbers in a max finder |
| All same values | No variety in input | [5, 5, 5, 5] passed to a sorting function |
| Very large values | Extreme inputs that might overflow or slow down | Very long strings, very large numbers |
| Input | Expected | Why This Input Matters |
|---|---|---|
| 95 | A | Middle of the A range — typical case |
| 90 | A | Exact lower boundary of A — tests >= vs > |
| 89 | B | Just below A — tests the boundary from the other side |
| 80 | B | Exact lower boundary of B |
| 79 | C | Just below B |
| 70 | C | Exact lower boundary of C |
| 60 | D | Exact lower boundary of D |
| 59 | F | Just below D |
| 100 | A | Maximum possible — tests the top extreme |
| 0 | F | Minimum — tests the bottom extreme |
The best time to prevent debugging difficulty is while writing code, not after. Incremental development is a programming approach that builds programs in small, tested pieces rather than writing everything at once and then trying to make it work.
The core principle: write a little, test it, fix any problems, then write a little more. This approach keeps the gap between working code and untested code very small. If a bug appears after you add a new piece, you know the bug is almost certainly in that new piece — not buried somewhere in fifty lines you wrote all at once.
Writing the entire program, running it for the first time, and finding it does not work forces you to debug a large untested system where the bug could be anywhere. Every component is suspect. Beginning programmers often feel it is more efficient to write everything before testing — in practice, the debugging time at the end far exceeds the testing time that could have been distributed throughout.
Write and test the simplest possible version: read a list and print it.
scores = [88, 92, 74, 95, 81, 67, 78]
print("Scores:", scores)Does it print correctly? Yes. Move on.
Add the calculation of the total.
scores = [88, 92, 74, 95, 81, 67, 78]
total = sum(scores)
print("Total:", total)Is the total correct? 88+92+74+95+81+67+78 = 575. Yes. Move on.
Add the average calculation.
total = sum(scores)
count = len(scores)
average = total / count
print("Average:", round(average, 2))Is the average correct? 575 / 7 ≈ 82.14. Yes. Move on.
Add highest and lowest scores.
highest = max(scores) lowest = min(scores) print("Highest:", highest) print("Lowest:", lowest)
Correct? Yes. Move on.
Clean up output, remove intermediate prints, finalize.
If a bug appeared at step 3, it was almost certainly in the code added at step 3 — not buried in everything written since the beginning. Each step was a small, verified addition.
Start with the simplest version that does something visible. Make each addition as small as possible. Test after every addition. Keep a working version at all times — never write so much untested code that you cannot return to a working state.
Writing correct code is necessary. Writing code that others — and your future self — can understand is equally important. Documentation and program design are the practices that make programs readable, maintainable, and collaborative.
| Form | What It Is | Where It Appears |
|---|---|---|
| Comments | Notes inside code marked with #, ignored by Python | Next to tricky lines, at the top of functions and files |
| Docstrings | String literals below a function definition that describe its purpose | First line inside every function body |
| External docs | Separate files, readme documents, or written guides | Project folder, shared team documentation |
# Assign 10 to count
count = 10
# Start count at 10: the survey skips the first 10 warm-up responses count = 10 # Guard against zero before dividing to prevent ZeroDivisionError if count > 0: average = total / count
def calculate_average(scores): """ Calculate and return the arithmetic mean of a list of numbers. Parameters: scores: a list of numbers (may be empty) Returns: The arithmetic mean of the values in scores. Returns 0.0 if the list is empty. """ if len(scores) == 0: return 0.0 return sum(scores) / len(scores)
| Poor Name | Better Name | Why |
|---|---|---|
x | score | Describes what the variable holds |
lst | student_names | Specific about the contents |
temp | temperature_celsius | Includes units to prevent confusion |
do_thing | convert_to_fahrenheit | States exactly what the function does |
flag | is_valid | Boolean names read like true/false questions |
n | count | Describes the role, not just a letter |
1. What is this program supposed to do? (State it clearly in plain language.)
2. What are the inputs? What values, files, or information does it receive?
3. What are the outputs? What should the program produce?
4. What are the major steps? (Write pseudocode or a task list first.)
5. What functions are needed? Which parts of the work are distinct enough to be separated?
6. What can go wrong? Identify edge cases before they become bugs.
Good program design makes testing easier — well-decomposed functions with clear purposes can be tested individually. Good design makes debugging easier — clear names and useful comments make it much faster to understand what each piece of code is supposed to do and to notice when it is not doing it. Design, testing, and debugging are not separate activities. They are three aspects of a single discipline: writing programs that actually work.
# that the interpreter ignores, used to explain purpose, reasoning, or structure.For each description, identify the error type: syntax error, runtime error, or logic error. Explain your answer in one sentence.
SyntaxError: expected ':' on line 6.ZeroDivisionError.NameError: name 'total' is not defined when the program runs.IndexError: list index out of range.Each program below contains one error. Identify the type of error, explain what it is, and write a corrected version.
What type of error is this? Where exactly is the mistake? Write the corrected version.
What type of error is this? What is wrong? What happens when you run this program? Write the corrected version.
What type of error is this? Trace the function by hand with [4, 1, 8, 2] to show what it actually returns. What is the correct version?
Identify the logic error in the total_with_tax calculation. What is the correct formula? What does the current code actually compute?
For each function described below, design at least four test cases. Give the input and the expected output for each. At least one test case should be an edge case.
is_even(n) that returns True if n is even and False if it is odd.clamp(value, minimum, maximum) that returns value if it falls between minimum and maximum, returns minimum if value is below, and returns maximum if value is above.count_vowels(word) that returns the number of vowels (a, e, i, o, u) in a given string.safe_divide(numerator, denominator) that returns the result of division if denominator is not zero, and returns 0.0 if it is zero.Trace each program by hand. Create a variable-tracking table at each step. Then write the exact output the program produces.
Trace all five iterations. Track: v, total, condition result. What does the program print?
Trace the function call mystery(5). Track: i, result at each iteration. What does this function compute? What does it print?
Trace all four iterations. Track: word, len(word), condition, count. What does the program print?
send_reminder(recipient, message, days_before) that schedules a reminder email. The function returns True if the reminder was scheduled successfully and False otherwise.A program is supposed to read a list of student names and test scores, calculate the class average, and identify which students scored above the average. Plan how to build this program incrementally.
For each function described below, list at least three edge cases that good tests should cover. For each, explain what might go wrong if the code does not handle it.
NameError: name 'score' is not defined (b) A function that computes total + count instead of total / count (c) SyntaxError: invalid syntax on line 8 (d) IndexError: list index out of rangeif x = 5: instead of if x == 5:. What type of error is this? Why?numbers = [3, 8, 1, 6, 2]. What does it print?
total = 0; for n in numbers: total = total + n; print(total / len(numbers))
letter_grade(score) that returns "Pass" for scores 60 and above and "Fail" for scores below 60. Include at least two edge cases.results = [x for x in scores if x >= passing_score]find_largest(numbers) that returns the largest value in a non-empty list.These questions draw on material from Chapters 5 through 10.
double_all(numbers) that takes a list of numbers and returns a new list where every value is doubled. Then design three test cases for it, including one edge case.Chapter 10 closes Part II of Computer Science Foundations. You have now learned the foundations of programming: algorithms, programming languages, variables and types, control flow, functions, and in this chapter, debugging, testing, and program design. These ideas are the foundation on which everything else in the programming world is built.
Chapter 11 opens Part III: Data and Algorithms. It introduces data structures — the organized containers that programs use to store, organize, and work with collections of data. You have already used one data structure in earlier chapters: the list. Chapter 11 examines lists more carefully and introduces strings as sequences, records, dictionaries, stacks, and queues. Each is designed for a different organizational need. The question that drives Chapter 11 is one that will keep appearing throughout computer science: not just how to process data, but how to organize it so that processing becomes efficient, correct, and clear.