AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Testing and Debugging Python Code

📚 Projects & Applied⏱️ 23 min read🎓 Grade 8
✍️ AI Computer Institute Editorial Team Updated: August 2026 CBSE-aligned · Peer-reviewed · 23 min read
Content curated by subject matter experts with IIT/NIT backgrounds. All chapters are fact-checked against official CBSE/NCERT syllabi.

Every year, CBSE marks 33 out of 100 as the passing score. If a student scores exactly 33, the report card must say "PASS" — not "FAIL". Now imagine you are asked to write a small Python program for your school that takes a student's marks and prints whether they passed. You write it, run it once with a mark of 90, see "PASS" printed, and submit it feeling confident. Nobody tells you that your program silently fails the one case that matters most: the boundary. A student who scored exactly 33 — the minimum passing mark — gets told they failed, when they should have passed.

This is not a rare, contrived scenario. Boundary mistakes like this are one of the most common bugs in real software — banking apps that mishandle the exact minimum balance, ticket-booking systems that mishandle the last seat, attendance trackers that mishandle exactly 75% attendance. Your code ran. It didn't crash. It printed an answer. And the answer was wrong. This chapter is about two related but different skills: testing, which is how you deliberately go looking for cases like this before they embarrass you, and debugging, which is how you find and fix the exact line responsible once you know something is wrong.

Testing and Debugging Are Not the Same Skill

It helps to separate these two words precisely, because students often use them interchangeably.

  • Testing means deliberately running your program with specific inputs for which you already know the correct answer, and checking whether your program's actual output matches that known-correct answer. Testing is a checking activity — its job is to tell you whether something is wrong.
  • Debugging means the investigation that follows once you know something is wrong: reading error messages, tracing through code line by line, and narrowing down the exact statement that produces the incorrect behaviour. Debugging is a diagnostic activity — its job is to tell you where and why something is wrong.

You cannot debug what you haven't first noticed is broken, and you cannot test intelligently without understanding what kinds of mistakes to look for. The two skills feed each other, and this chapter builds them in that order: first you'll learn to recognise the three fundamentally different ways Python code goes wrong, then you'll learn a set of concrete techniques — reading tracebacks, hand-tracing, print statements, and assert statements — for catching and fixing each kind.

Three Different Ways Code Can Be Wrong

Not all bugs are alike, and the technique you need depends entirely on which kind you're facing. Python code goes wrong in exactly three distinct stages, and it helps enormously to know which stage you're dealing with before you start hunting.

1. Syntax errors happen before your program ever runs. Python reads your entire file first to check that it is grammatically valid Python — matching brackets, colons after if/for/def, correctly indented blocks. If anything is grammatically broken, Python refuses to run even the first line.

def check(marks)
    return marks >= 33

Notice the missing colon after check(marks). Running this file produces:

  File "check.py", line 1
    def check(marks)
                    ^
SyntaxError: expected ':'

Nothing executed at all — not even a print statement placed before this function would have run. The caret (^) points to roughly where Python's parser gave up expecting a colon. Syntax errors are, in a strange way, the easiest bugs to deal with: Python tells you almost exactly where the grammar breaks, and once you supply the missing colon, bracket, or quote, the error disappears completely.

2. Runtime errors (Python calls them exceptions) happen after your program starts running successfully, but partway through execution, some statement asks Python to do something impossible — divide by zero, look up a list index that doesn't exist, open a file that isn't there. The program crashes mid-execution and prints a traceback.

def average(numbers):
    return sum(numbers) / len(numbers)

scores = []
print(average(scores))

Here, scores is an empty list. sum([]) is 0 and len([]) is 0, so the function tries to compute 0 / 0, which is undefined even in ordinary arithmetic. Python crashes with:

Traceback (most recent call last):
  File "avg.py", line 5, in <module>
    print(average(scores))
  File "avg.py", line 2, in average
    return sum(numbers) / len(numbers)
ZeroDivisionError: division by zero

3. Logical errors are the most dangerous kind, precisely because Python is completely silent about them. The program runs from start to finish without a single error message, produces some output, and that output is simply wrong. There is no traceback to point you anywhere — the only way to catch a logical error is to already know what the correct answer should have been, and to have actually checked. This is exactly the CBSE 33-marks bug from the opening of this chapter, and it's why testing — deliberately comparing actual output against known-correct output — is not optional extra work. It is the only defence against this entire category of bug.

The diagram below shows how these three failure points sit along the journey your code takes from a text file to a finished answer.

Your source code (.py file) Stage 1: Python parses the whole file for grammar SyntaxError Program never starts running at all e.g. missing colon, unmatched bracket grammar OK Stage 2: Interpreter executes statements one by one Runtime Error (Exception) Program crashes mid-execution e.g. ZeroDivisionError, IndexError Python prints a traceback no crash Program finishes normally, produces some output Stage 3: Testing — compare actual output to the answer you already know is correct Logical Error No crash, no error message — just the wrong answer. Only testing can catch this. matches Output correct — this test case passes

Reading a Traceback Bottom-Up, Like a Detective

When Python crashes with a runtime error, students often panic and stare at the entire wall of red text without a strategy. There is a reliable strategy: read a traceback from the bottom upward, not top-down.

Look again at the ZeroDivisionError traceback above. The very last line — ZeroDivisionError: division by zero — tells you the category of mistake. The line just above it — return sum(numbers) / len(numbers) at line 2, in average — tells you the exact statement that was executing when the crash happened. Keep moving upward and each block tells you who called whom: line 5, in <module> shows that this all started because line 5, at the top level of the file, called average(scores). So the story, read bottom to top, is: "A division by zero happened, inside the average function, on the division line, because line 5 called it with an empty list." That is a complete diagnosis, and you built it by reading upward from the exception name.

Here is a second example, one where the traceback has to lead you to a subtler bug — an off-by-one error, one of the most common mistakes in loop-based code.

scores = [45, 89, 23, 102, 67]

def find_highest(scores):
    highest = scores[0]
    for i in range(1, len(scores) + 1):
        if scores[i] > highest:
            highest = scores[i]
    return highest

print(find_highest(scores))

The intention is clear: start with the first score as the current highest, then compare every remaining score against it. scores has 5 elements, at valid indices 0, 1, 2, 3, 4. But look closely at line 5: range(1, len(scores) + 1) is range(1, 6), which produces 1, 2, 3, 4, 5 — and 5 is not a valid index for a 5-element list. Running this crashes:

Traceback (most recent call last):
  File "scores.py", line 10, in <module>
    print(find_highest(scores))
  File "scores.py", line 6, in find_highest
    if scores[i] > highest:
IndexError: list index out of range

Reading bottom-up: IndexError: list index out of range tells you the category — some index was out of bounds. line 6, in find_highest tells you the exact statement, if scores[i] > highest:. line 10, in <module> tells you this chain started with the call on line 10. Once you know the crash is on line 6 with an out-of-range index, you go looking at what produces the index — line 5's range(1, len(scores) + 1) — and you notice it should stop one earlier. The fix is to drop the + 1:

def find_highest(scores):
    highest = scores[0]
    for i in range(1, len(scores)):
        if scores[i] > highest:
            highest = scores[i]
    return highest

print(find_highest(scores))

Now range(1, 5) produces 1, 2, 3, 4 — exactly the remaining valid indices. Tracing it by hand: start with highest = 45; at i=1, 89 > 45 so highest = 89; at i=2, 23 > 89 is false, no change; at i=3, 102 > 89 so highest = 102; at i=4, 67 > 102 is false, no change. The function returns 102, correctly, and the loop never touches an invalid index.

A Common Misconception: "It Ran Without Errors, So It's Correct"

Here is a belief that trips up almost every beginning programmer at some point: if my program runs to completion and doesn't show a red traceback, it must be working correctly. This is false, and the boundary-marks example at the start of this chapter is the proof. That program ran perfectly. It produced output. It just produced the wrong output for one specific, important input.

Python's interpreter only checks two things for you automatically: whether your code is grammatically valid (syntax errors) and whether some operation is mathematically or structurally impossible while running (runtime errors). Python has absolutely no way of knowing what answer you intended — it cannot know that a student scoring exactly 33 should be told "PASS", because that fact lives only in your head and in the CBSE rulebook, not in the Python language. Checking your code against your own intentions is a job only testing can do, and it is a job you must do deliberately, every time — not just once, with one input that happens to work.

Hand-Tracing: Finding a Logical Error With a Table

When there's no traceback to guide you, the most disciplined debugging technique is to trace the code by hand, line by line, writing down the value of every variable as it changes — exactly the way the Python interpreter itself would. Take the buggy pass/fail checker:

def result(marks):
    if marks > 33:
        return "PASS"
    else:
        return "FAIL"

print(result(33))

Tracing the call result(33) by hand:

Step  Statement executed        marks   Condition (marks > 33)   Outcome
1     result(33) called          33      —                        —
2     if marks > 33:              33      33 > 33  ->  False        go to else branch
3     return "FAIL"               33      —                        function returns "FAIL"

Expected output: "PASS"   (33 is CBSE's own passing mark — it must not fail)
Actual output:   "FAIL"   <- bug: the boundary value itself is misclassified

The trace makes the bug undeniable: at exactly marks = 33, the strict "greater than" comparison evaluates to False, sending execution into the else branch. The fix is a one-character change — > becomes >= — but hand-tracing is what let you locate that character with certainty, instead of guessing.

Print-Statement Debugging: The Cheapest First Tool

Before reaching for anything fancier, the fastest way to check what a program is actually doing at a particular moment is to insert a temporary print() call right at the point you're unsure about. If you suspected the boundary bug above but hadn't traced it yet, you might write:

def result(marks):
    if marks > 33:
        return "PASS"
    else:
        print("DEBUG: marks was", marks, "- went to else branch")
        return "FAIL"

print(result(33))

Running this prints DEBUG: marks was 33 - went to else branch followed by FAIL. Seeing the word "else" fire for marks = 33 — a value you expected to be a pass — is often enough on its own to make you go back and re-read the comparison operator. Print-statement debugging is deliberately low-tech: no special tools, works everywhere, and it's the technique professional programmers still reach for first, most often, even after years of experience. Its only downside is that you must remember to delete the debug print() lines before you consider the code finished.

assert Statements: Turning Expectations Into Automatic Checks

Hand-tracing and print debugging both require you to notice, by eye, that an output looks wrong. A more powerful technique is to write down what you expect the correct answer to be before you run the code, and let Python itself check it for you every single time. This is exactly what the assert statement does. Its form is:

assert <condition>, <message shown if the condition is false>

If the condition is true, assert does nothing at all and execution continues silently. If the condition is false, Python immediately raises an AssertionError with your message, stopping the program right there. This means you can write a whole battery of known-correct input/output pairs as a permanent, reusable checklist. Take the (still buggy) pass/fail checker and test it against five marks, deliberately including the boundary:

def result_status(marks):
    if marks > 33:
        return "PASS"
    else:
        return "FAIL"

assert result_status(0) == "FAIL", "0 marks should fail"
assert result_status(32) == "FAIL", "32 marks should fail"
assert result_status(33) == "PASS", "33 marks should pass (boundary)"
assert result_status(34) == "PASS", "34 marks should pass"
assert result_status(100) == "PASS", "100 marks should pass"
print("All tests passed!")

Run this and Python checks the first two asserts silently (both correct: 0 and 32 both fail as expected), then reaches the third one. result_status(33) returns "FAIL" because of the same > bug as before, and "FAIL" == "PASS" is False, so the assertion fails immediately:

Traceback (most recent call last):
  File "marks.py", line 9, in <module>
    assert result_status(33) == "PASS", "33 marks should pass (boundary)"
AssertionError: 33 marks should pass (boundary)

Notice how much more informative this is than a plain wrong-answer bug you had to spot yourself: the traceback names the exact line, shows the exact assertion that failed, and prints your own custom message explaining what was expected. This is precisely why professional test suites are built almost entirely out of statements like this one — they convert "I believe this should always be true" into something Python enforces automatically, every time you run the code, forever.

Fixing the Bug and Regression Testing

Now that hand-tracing and the failing assertion have both pinpointed the exact problem — a strict > where an inclusive >= was needed — the fix is a single character:

def result_status(marks):
    if marks >= 33:
        return "PASS"
    else:
        return "FAIL"

assert result_status(0) == "FAIL", "0 marks should fail"
assert result_status(32) == "FAIL", "32 marks should fail"
assert result_status(33) == "PASS", "33 marks should pass (boundary)"
assert result_status(34) == "PASS", "34 marks should pass"
assert result_status(100) == "PASS", "100 marks should pass"
print("All tests passed!")

Running this now: result_status(33) evaluates 33 >= 33 as True, returning "PASS", matching the assertion. Every one of the five assertions passes silently, and the program prints All tests passed!.

It's worth noticing why the test suite kept the easy cases (0, 32, 34, 100) even after you already knew the real bug was at 33. This is called regression testing: every time you change code to fix one bug, you re-run all your old test cases, not just the one that was failing — because a fix aimed at one case can easily break a different case that used to work. Here, changing > to >= happens to be a completely safe change, but if you had instead "fixed" the bug by writing if marks == 33: return "PASS" as a special case, the boundary test would pass while the 34 and 100 tests would suddenly start failing. Keeping the full set of assertions and re-running all of them after every change is what catches mistakes like that immediately, instead of weeks later when someone with 87 marks is wrongly told they failed.

A Repeatable Four-Step Debugging Method

Put together, the techniques above form a method you can apply to almost any bug, in roughly this order:

  1. Reproduce it reliably. Find the smallest, simplest input that triggers the wrong behaviour every single time. An empty list, the number zero, and boundary values (like exactly 33) are the classic troublemakers — deliberately try them first.
  2. Read the evidence carefully. If Python crashed, read the traceback bottom-up: exception type, then the exact failing statement, then the chain of calls that led there. If nothing crashed but the answer is wrong, hand-trace the code with a table, writing down every variable's value at every step.
  3. Form a specific hypothesis. Don't just say "something's wrong with the loop." Say something falsifiable: "I think range(1, len(scores) + 1) is producing one index too many." A vague hypothesis leads to vague, unproductive changes.
  4. Make the smallest change that tests your hypothesis, then re-run every assertion you have — not just the one case you were chasing. This last step is what separates a real fix from a lucky guess, and it's what regression testing is for.

Practice: Test Yourself

  1. Classify each of these as a syntax error, a runtime error, or a logical error, and explain why: (a) a program that computes simple interest but always shows an answer ten times too large because it forgot to divide the rate by 100; (b) a program that stops with IndentationError: expected an indented block; (c) a program that stops with KeyError: 'roll_number' while reading a dictionary of student records.
  2. A function def grade(marks): return "Fail" if marks < 33 else "Pass" is tested with assert grade(33) == "Pass", "boundary check". Trace through it by hand and state whether this assertion passes or fails, and why.
  3. A list attendance = [78, 92, 65, 88] is passed to a function that loops with for i in range(len(attendance) + 1): and accesses attendance[i] inside the loop. Predict the exact exception this raises, and state which index value triggers it.
  4. Write two assert statements — with clear failure messages — that would have caught the CBSE boundary bug (marks > 33 instead of marks >= 33) even before you ran the whole program: one for a mark that should clearly pass and is unaffected by the bug, and one specifically targeting the value where the bug lives.
  5. Explain, in your own words, why "the program ran without any error message" is not sufficient evidence that a program is correct. Give one example, different from the ones in this chapter, of a program that could run cleanly yet produce a logical error.

Summary

Python code fails in exactly three distinguishable ways, and each demands a different response. A syntax error stops your program before it ever starts, because the grammar itself is broken — Python's own parser catches these for you, and the fix is almost always local to the line the caret points at. A runtime error stops your program partway through, because some statement demanded something impossible, like dividing by zero or indexing past the end of a list — Python hands you a traceback for these, which you read from the bottom upward: exception type first, then the exact failing statement, then the chain of calls above it. A logical error is the most dangerous of the three because Python gives you no warning whatsoever — the program finishes and prints an answer, and that answer is simply wrong; catching this category is entirely your responsibility, and it is exactly what testing exists to do.

Two disciplined techniques do most of the real work: hand-tracing a program line by line in a table to watch exactly how its variables change, and writing assert statements that encode "I already know what this answer should be" so that Python checks it automatically, every time, instead of relying on you to notice by eye. And once you fix any bug, regression testing — rerunning your entire set of old assertions, not just the one that failed — is what stops today's fix from quietly becoming tomorrow's new bug. A program that merely runs without crashing has cleared the lowest possible bar. A program that has been deliberately tested against its trickiest boundary cases, including the ones exactly like CBSE's own 33-mark pass line, is one you can actually trust.

← Python Modules and PackagesVersion Control with Git →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn