4.2 Debugging and error handling

Debugging is the process of identifying and fixing defects in code. It is an essential skill for programmers to develop. In this course, you will encounter various errors while writing Python code. Understanding how to debug your code effectively will help you become a better programmer.

4.2.1 Common types of errors

Errors in Python code can be broadly classified into three categories:

  • Syntax Errors: These errors occur when the code violates the rules of the Python language. Python reports them before it runs any of the file, so a file with a syntax error produces no output at all.
  • Runtime Errors: Runtime errors occur while the program is running. The file is valid Python, but an operation fails as it executes, for example dividing by zero, indexing past the end of a list, or opening a file that does not exist. Python stops and reports the exception.
  • Logic Errors: Logic errors are the most challenging to identify. They occur when the code runs without throwing any errors but produces incorrect results. Logic errors require careful analysis of the code to identify and fix.

4.2.2 Debugging techniques

Here are some common techniques you can use to debug your Python code:

  1. Print Statements: Use the print function to report a value at the moment execution reaches a line. The section on tracing a program with print below works through a defect this way, from the first print to the correction.
  2. Breakpoints: Set breakpoints in your code to pause execution at specific lines and inspect the state of variables.
  3. Watch Expressions: Use watch expressions in your IDE to monitor the value of variables as the code executes.
  4. Exception Handling: Implement error handling routines to gracefully handle exceptions and provide meaningful error messages.
  5. Testing: Write test cases to verify the correctness of your code and catch errors early in the development process.
  6. Rubber Duck Debugging: Explain your code line by line to an inanimate object (like a rubber duck) to identify issues. Many times, just by articulating the problem, the solution becomes apparent. For example, you can use ChatGPT as a "virtual rubber duck" to help you debug your code.
  7. Logging: Write output to a log file to track the program's execution and identify errors.
  8. Code Review: Have a peer review your code to identify potential issues and suggest improvements.
  9. Assertions: Use assert statements to state what must be true at a point in your code, so that a broken assumption stops the program instead of travelling through it. The section on checking assumptions with assert below covers the statement, its failure message, and the cases it does not suit.

4.2.3 Debugging with VS Code

Visual Studio Code (VS Code) is a popular code editor that provides a powerful debugger for Python. Here are some common debugging features in VS Code:

  • Breakpoints: Set a breakpoint by clicking in the narrow margin to the left of the line number, or by pressing F9 on the line. When execution reaches the breakpoint, it pauses before that line runs, so you can inspect the state of variables and step through the code. A breakpoint shows as a red dot; clicking the dot again removes it. To keep a breakpoint but stop it from pausing execution, disable it from the Breakpoints panel or from its right-click menu.
  • Step Over: Execute the current line and move to the next line in the code
  • Step Into: If the current line calls a function, step into the function to debug it
  • Step Out: Finish debugging the current function and return to the calling function
  • Continue: Resume execution until the next breakpoint is encountered
  • Watch: Monitor the value of variables as the code executes by adding them to the watch list in the debugger panel. You can see the value of variables change in real-time as the code runs.
  • Call Stack: View the call stack to see the sequence of function calls that led to the current point in the code execution flow.
  • Debug Console: Use the debug console to execute Python code and interact with the program during debugging.
  • Variables: View the values of variables in the current scope and inspect their contents. The variables are further categorized into Locals and Globals so you can see what belongs to each scope. You can also modify variable values during debugging to test different scenarios.

Figure 1 shows the debugging interface in VS Code with various features highlighted. You can use these features to step through your code, inspect variables, and identify issues. For Python debugging, you could follow the steps outlined on the tutorial: Python debugging in VS Code.

Figure 1: In this video, you'll learn how to get started with debugging in Visual Studio Code (VS Code). The video covers setting breakpoints, stepping through code, inspecting variables, and using the debug console. You can find more information in the official VS Code documentation.

4.2.4 Tracing a program with print

The quickest way to see what a program is doing is to make it say so. A print statement placed inside the code reports a value at the moment execution reaches that line. It needs no tools and no configuration, which is why it is usually the first thing to reach for.

The function in Listing 1 classifies a number, and it is wrong: it reports 0 as negative. Nothing in the output explains why.

Listing 1: A function that returns the wrong answer for 0, with no indication of the cause.

Remove the prints when you are done

Tracing prints are scaffolding, not output. Left behind, they clutter the console of every later run and mix debugging chatter into results that someone else has to read. Delete them once the defect is found, as Listing 4 does.

This is also the limit of the technique. Printing shows you a value only while you are watching the console, and only for the run in front of you. To make the program check itself on every run, state the expectation with an assert, described next.

4.2.5 Checking assumptions with assert

Breakpoints and print statements show you what a program is doing while you watch it. An assert statement makes the program check itself, every time it runs, without you watching.

An assertion states something you believe is true at that point in the code. Python evaluates the condition: when it is True, execution continues and nothing is printed; when it is False, the program stops immediately with an AssertionError. A logic error that would otherwise travel silently through the rest of the program becomes a loud failure on the line where your assumption broke.

Listing 5 shows the quiet case. A water temperature cannot fall below absolute zero, and this one does not, so the assertion produces no output at all.

Listing 5: A passing assertion. Nothing is printed by the assert itself.

Writing the failure message

An assertion that fails is only useful if it tells you what went wrong. The statement takes an optional message after the condition, assert condition, message, and that message appears in the traceback.

The script in Listing 6, saved as shares.py, states that three percentages add up to a whole. They do not, and Listing 7 shows what Python reports when the file is run.

Listing 6: An assertion that fails because the shares add up to 97.
morning = 45
afternoon = 30
evening = 22
total = morning + afternoon + evening
assert total == 100, f"the shares must add up to 100, got {total}"
Listing 7: The traceback of Listing 6. The carets mark the condition that failed, and the last line reports the message with the offending value.
Traceback (most recent call last):
  File "shares.py", line 5, in <module>
    assert total == 100, f"the shares must add up to 100, got {total}"
           ^^^^^^^^^^^^
AssertionError: the shares must add up to 100, got 97

Write the message so that it names the value that broke the assumption, as the f-string above does with {total}. A bare AssertionError tells you only the line; a message tells you the state.

Checking a function against known cases

The most common use of assertions in this course is testing: you call a function with an input whose answer you already know, and assert that the function returns it. Listing 8 collects every case in one test function, so that a single call checks them all.

Listing 8: Testing a function against known cases. Each case stores the returned value in got, so the message can report what the function actually produced.

Include the boundaries among the cases. Here 0 separates the positive from the negative branch, and it is the input most likely to expose a wrong comparison operator. You have already met this pattern in the exercises of the Conditionals chapter, where each exercise ends with a block of assertions that your function has to satisfy.

Checking an assumption inside a function

Assertions are not only for tests. Placed inside a function, they record what the code requires to be correct. In Listing 9, the discount percentage has to lie between 0 and 100. If a caller ever passes 120, the function stops at the assertion instead of quietly returning a negative price.

Listing 9: An assertion that states what the function requires of its caller.

What assertions are not for

An assertion is a statement about your own code, addressed to you. It is not a way to handle a situation you expect to happen.

Python can be run with the -O (optimize) option, and every assert statement is then skipped. Code whose correctness depends on an assertion running will therefore break silently under python -O. This makes assertions the wrong tool for anything outside your control:

  • a value typed by a user,
  • a line read from a file,
  • a response from another program.

Those are expected conditions, not broken assumptions. Check them with an if statement, or handle the resulting error with the try-except statement described next.

4.2.6 Reading the call stack

The Functions chapter introduced the call stack: each call adds a frame, each return removes one, and every frame keeps its own local variables. When a program fails, that stack is the most useful thing on the screen, because the traceback is the call stack written out at the moment the error was raised.

The program in Listing 10, saved as orders.py, divides a total by a count. Nothing in unit_price looks wrong, and the fault is not there.

Listing 10: Three functions that call one another. report supplies the count that unit_price eventually divides by.
def unit_price(total, count):
    return total / count


def line_summary(total, count):
    return unit_price(total, count)


def report():
    return line_summary(90.0, 0)


print(report())

Running orders.py produces Listing 11.

Listing 11: The traceback of Listing 10. Each File entry is one frame of the call stack.
Traceback (most recent call last):
  File "orders.py", line 13, in <module>
    print(report())
          ~~~~~~^^
  File "orders.py", line 10, in report
    return line_summary(90.0, 0)
  File "orders.py", line 6, in line_summary
    return unit_price(total, count)
  File "orders.py", line 2, in unit_price
    return total / count
           ~~~~~~^~~~~~~
ZeroDivisionError: division by zero

Read a traceback from the bottom

The frames are printed oldest first, so the order on screen is the order the calls were made. Two lines carry most of the information:

  • the last line names the error, ZeroDivisionError: division by zero;
  • the frame just above it is where the program stopped, unit_price, line 2.

Read upwards from there and the traceback answers the next question by itself: unit_price was called by line_summary, which was called by report, which passed the literal 0. The defect is in report, four lines away from the line that failed. The bottom frame tells you where it broke; the frames above tell you how execution got there.

Figure 2 shows the same four frames as a stack, with the initial frame of the program at the bottom and the failing call at the top.

graph BT
    classDef failing fill:#fde8e8,stroke:#c0392b,stroke-width:2px;

    subgraph Stack ["Call stack at the error"]
        direction BT
        D["&lt;module&gt;"]
        C["report()"]
        B["line_summary()"]
        A["unit_price()"]
        D --> C
        C --> B
        B --> A
    end
    class A failing;

    linkStyle default display:none;

Figure 2: The call stack when ZeroDivisionError is raised, with the initial frame of the program at the bottom and the failing call at the top. The traceback in Listing 11 prints these frames from the bottom upwards, so its last frame is this diagram's top box.

The same stack in the debugger

The Call Stack panel described earlier in this chapter shows exactly these frames, with one difference that makes it more useful than the printed traceback: the program is still paused, so the frames are live. Selecting report in the panel switches the Variables view to that frame, where the literal 0 is visible before it is passed on.

This is the fastest route through a failure in a program of several functions: run to the error, read the bottom frame for the cause, then click up the stack until you reach the frame whose values are wrong. That frame holds the defect, and it is rarely the one that raised the error.

4.2.7 Conditional statements for error handling?

In Python, conditional statements can check a condition before an operation. For example, you can check whether a file exists before reading it or whether a value is None before using it. Exceptions report errors that arise while a program runs. A try-except block can handle a specified exception when the operation raises it.

In Listing 12, we compare two ways to handle a division by zero error. The first function uses an if statement to check if the denominator is zero before performing the division. The second function uses a try-except block to catch the ZeroDivisionError exception that occurs when dividing by zero.

Listing 12: Example of handling a division by zero error using an if statement and a try-except block.
(a) Preventing a division by zero error using an if statement to check if the denominator is zero before performing the division.
(b) Handling the ZeroDivisionError exception using a try-except block. When an exception occurs, the code inside the except block is executed.

Choose the handling that matches the function's contract. A conditional check can handle an expected condition before an operation. A specific except block handles the corresponding exception raised during the operation and keeps that response separate from the normal calculation. In Listing 12, the try-except block can be further extended to handle other exceptions that may occur during the division operation, for example, TypeError if the input is not a number. For example, in Listing 13, we extend the divide_v2 function to handle both ZeroDivisionError and TypeError exceptions.

Listing 13: Extending the divide_v2 function to handle both ZeroDivisionError and TypeError exceptions using a try-except block.

4.2.8 Try-except statement

The try-except statement is used to enable error handling within Python code. There are several ways to use the try-except statement:

  • try-except: This statement tells Python to execute the code in the try block and handle any exceptions that occur in the except block.
  • try-except-else: This statement adds an else block that is executed if no exceptions occur in the try block.
  • try-except-finally: This statement adds a finally block that is executed regardless of whether an exception occurs or not.

Reading a number that somebody typed is the standard case. int(text) raises a ValueError when the text is not a whole number, and no if statement can check that in advance without repeating the work that int already does. In Listing 14, the try block attempts the conversion and the except block supplies a fallback.

Listing 14: Converting typed text to a whole number. The try block runs the conversion, and the except ValueError block returns 0 for text that is not a whole number, including "7.5" and the empty string.

This is the division of labor promised in the section on assertions. Text arriving from a person is an expected condition, not a broken assumption, so it is handled rather than asserted. Keep the try block to the statement that can fail, and name the exception you expect, as except ValueError does here. A bare except would also swallow a misspelled function name and leave you debugging a fallback value instead of a typing error.

4.2.9 Exercises

4.2.Q1 Trace argument mapping and scope

A variable's scope determines where its name is accessible in the code. In Listing 15, the Python function calculate takes 2 input parameters, a and b, and returns a floating-point value. The main function initializes variables a, b, and c, calls calculate, and prints the result.

The comments mark 3 breakpoints (BREAKPOINT 1, BREAKPOINT 2, and BREAKPOINT 3) where the variable values are inspected before the next statement executes.

Listing 15: Functions calculate and main with positional arguments, local variables, and 3 breakpoint comments.
def calculate(a: float, b: float) -> float:
    c: float = 0.0
    result: float = 0.0

    # BREAKPOINT 2
    if c < b + a:
        c = a * 2
        result = -1.0

    # BREAKPOINT 3
    result = a + b + c
    return result


def main() -> None:
    a: float = 2.0
    b: float = 3.0
    c: float = 4.0

    c = calculate(b, a)

    # BREAKPOINT 1
    print(c)


if __name__ == "__main__":
    main()

Complete the following tasks:

  1. Map the 2 arguments in calculate(b, a) to the parameters of calculate. Give each caller value and the corresponding local parameter value.
  2. Complete Table 1. Record the active function and the values visible in that function at each breakpoint. Write not in scope when a listed name is not defined in the active function.
  3. Calculate the value returned by calculate.
  4. Explain why a and b in main remain unchanged while c in main changes.
Table 1: Trace of the active function and its visible values at each breakpoint.
Breakpoint Active function a b c result
BREAKPOINT 2
BREAKPOINT 3
BREAKPOINT 1

4.2.Q2 Tracing nested function calls

Turkish names can contain spaces, punctuation, and special characters that need adjustment before the names are used as folder names. The functions in Listing 16 each perform 1 adjustment.

Listing 16: Python helper functions that prepare text for use as a folder name.
def trim_spaces(text: str) -> str:
    """Remove leading and trailing whitespace."""
    result = text.strip()
    print(f'"{result}"')
    return result


def strip_punct(text: str) -> str:
    """Remove punctuation symbols."""
    result = text.replace("!", "")
    result = result.replace("?", "")
    result = result.replace(".", "")
    result = result.replace(",", "")
    print(f'"{result}"')
    return result


def to_lower(text: str) -> str:
    """Convert the text to lowercase."""
    result = text.lower()
    print(f'"{result}"')
    return result


def to_regular(text: str) -> str:
    """Replace special characters."""
    result = text.replace("ç", "c")
    result = result.replace("Ç", "C")
    result = result.replace("ğ", "g")
    print(f'"{result}"')
    return result


def add_underscores(text: str) -> str:
    """Replace spaces with underscores."""
    result = text.replace(" ", "_")
    print(f'"{result}"')
    return result

The functions in Listing 17 use the helpers in one nested expression. The exclamation mark and trailing spaces are part of the input.

Listing 17: Python code that applies the nested calls to "Çiğdem Yildiz! ".
def format_folder_name(text: str) -> str:
    """Prepare a name for folder format."""
    return add_underscores(
        to_regular(
            to_lower(
                strip_punct(
                    trim_spaces(text)
                )
            )
        )
    )


def test_format_folder_name() -> None:
    name_text = "Çiğdem Yildiz!       "
    print(f'"{name_text}"')
    print(f'"{format_folder_name(name_text)}"')


if __name__ == "__main__":
    test_format_folder_name()

Complete the following tasks without executing the code.

  1. Number the 5 helper-function calls in their execution order.
  2. Write all 7 lines printed by test_format_folder_name, including the quotation marks and repeated final value.
  3. For each helper call, record its input and returned value.
  4. Rewrite format_folder_name without nested calls. Store each intermediate value in a clearly named local variable and return the final value.
  5. Explain why the final folder name is printed twice.