Debugging Techniques


The most fundamental debugging technique in any programming language is using print() statements to inspect the state of your program at various points. By strategically placing print() calls, you can trace the execution flow, check the values of variables, and verify whether a specific block of code is being executed. It's simple, requires no special tools, and is often the quickest way to find a simple bug.

Note: This method is a cornerstone of "debugging in Python," allowing developers to "trace code with print" and "check variable values" during execution. While not sophisticated, its simplicity makes it a go-to for quick diagnostics.

 

Example 1: Simple Variable Check

This example demonstrates the most basic use of print(): checking a variable's value after an operation.

code:

# A simple script to calculate the area of a rectangle
length = 10
width = 5

# The calculation is performed here
area = length * width

# --- Debugging Print Statement ---
# We print the value of 'area' to ensure the calculation is correct.
print(f"The calculated area is: {area}")
# ------------------------------------

# Expected output is 50. If it's different, we know the error is in the line above.
if area == 50:
    print("Calculation is correct.")
else:
    print("There is an error in the calculation.")

Explanation

In this code, the print(f"The calculated area is: {area}") line is not part of the program's core logic but is inserted purely for debugging. It allows us to see the result of the length * width multiplication immediately. If the output was unexpected (e.g., not 50), we would know the problem lies in the calculation itself or in the initial values of length and width.

 

Example 2: Tracking Loop Iterations

When working with loops, bugs can occur in a specific iteration. Using print() inside a loop helps you see the state of variables with each pass.

code:

# A script to sum the first five even numbers
total_sum = 0
even_numbers_found = 0
current_number = 0

while even_numbers_found < 5:
    # --- Debugging Print Statement ---
    # Print the state at the beginning of each loop iteration
    print(f"--- Iteration Start ---")
    print(f"Current Number: {current_number}, Total Sum: {total_sum}, Even Numbers Found: {even_numbers_found}")
    # ------------------------------------
    
    if current_number % 2 == 0:
        total_sum += current_number
        even_numbers_found += 1
    
    current_number += 1

print(f"\nFinal Sum: {total_sum}") # Expected: 0 + 2 + 4 + 6 + 8 = 20

Explanation

The print() statements inside the while loop act as a log. For each iteration, they report the values of current_number, total_sum, and even_numbers_found. This allows you to trace the process step-by-step. If the final sum were incorrect, you could look at this log to pinpoint exactly where the logic went wrong—for instance, if an odd number was accidentally added.

 

Example 3: Conditional Logic Flow

It's common for bugs to hide in complex if-elif-else structures. A print() statement can confirm which path your code is actually taking.

code:

# A function to determine a student's grade
def get_grade(score):
    # --- Debugging Print Statement ---
    print(f"Evaluating score: {score}")
    # ------------------------------------
    
    if score >= 90:
        # --- Debugging Print Statement ---
        print("Condition met: score >= 90")
        # ------------------------------------
        return "A"
    elif score >= 80:
        # --- Debugging Print Statement ---
        print("Condition met: score >= 80")
        # ------------------------------------
        return "B"
    elif score >= 70:
        # --- Debugging Print Statement ---
        print("Condition met: score >= 70")
        # ------------------------------------
        return "C"
    else:
        # --- Debugging Print Statement ---
        print("Condition met: else block")
        # ------------------------------------
        return "D"

# Test the function
student_score = 85
grade = get_grade(student_score)
print(f"The final grade for a score of {student_score} is {grade}.")

Explanation

Here, the print() statements act as signposts. When you run get_grade(85), the output will show Evaluating score: 85 followed by Condition met: score >= 80. This instantly confirms that the program correctly entered the elif score >= 80: block and skipped the if score >= 90: block. If the wrong grade were returned, these printouts would immediately show you where the logical error occurred.

 

Example 4: Function Input and Output

When you have functions calling other functions, it's crucial to know what data is going in and what is coming out.

code:

# A script with interacting functions
def process_data(data):
    # --- Debugging Print Statement ---
    print(f"[process_data] Received input: {data}")
    # ------------------------------------
    
    # Simulate some processing
    processed_value = data * 2
    
    # --- Debugging Print Statement ---
    print(f"[process_data] Returning value: {processed_value}")
    # ------------------------------------
    return processed_value

def main_calculation(x):
    # --- Debugging Print Statement ---
    print(f"[main_calculation] Starting with value: {x}")
    # ------------------------------------
    
    y = process_data(x)
    
    result = y + 5
    
    # --- Debugging Print Statement ---
    print(f"[main_calculation] Final result: {result}")
    # ------------------------------------
    return result

# Run the main function
main_calculation(10)

Explanation

This example uses prefixed print() statements ([process_data], [main_calculation]) to clarify which function is logging the message. This is essential in larger programs. The prints verify that process_data receives the correct input (10) from main_calculation and that it returns the expected value (20) back to it.

 

Example 5: Debugging a Recursive Function

Recursion can be difficult to trace. print() statements can help visualize the call stack by showing the state at each level of recursion.

code:

# A recursive function to calculate factorial
def factorial(n, level=0):
    # --- Debugging Print Statements ---
    # Indent based on recursion depth for readability
    indent = "  " * level 
    print(f"{indent}--> factorial({n}) called, level={level}")
    # ------------------------------------

    if n == 0:
        # --- Debugging Print Statements ---
        print(f"{indent}<-- Base case reached. Returning 1.")
        # ------------------------------------
        return 1
    else:
        # Recursive step
        result = n * factorial(n - 1, level + 1)
        # --- Debugging Print Statements ---
        print(f"{indent}<-- Returning {n} * result_from_level_{level+1} = {result}")
        # ------------------------------------
        return result

# Calculate factorial of 4
print("\nCalculating factorial of 4:")
factorial(4)

Explanation

In this advanced example, we not only print values but also use an indentation level to represent the depth of the recursion. The output clearly shows factorial(4) calling factorial(3), which calls factorial(2), and so on, until the base case n == 0 is reached. It then shows the return values propagating back up the call stack. This visual trace is invaluable for understanding how recursion works and for finding bugs in the base case or recursive step.

 

 

Python Debugger (pdb)

When print() statements become too cumbersome, it's time to use a real debugger. Python's built-in debugger, pdb, is a powerful command-line tool that allows you to pause your program's execution at a specific point (a "breakpoint") and inspect the state of your application interactively. You can step through your code line by line, examine variables, and execute commands in the context of the paused program.

Note: Learning pdb is essential for any serious Python developer. Key search terms include "Python pdb tutorial," "interactive debugging in Python," "how to use pdb," and "Python command-line debugger."

 

Example 1: Basic Breakpoint

This shows the fundamental use of pdb: setting a breakpoint to halt execution.

code:

import pdb

def calculate_inverse(value):
    # A simple function that could fail
    print("Preparing to calculate inverse...")
    
    # Set a breakpoint right before the potentially problematic line
    pdb.set_trace() 
    
    result = 1 / value
    print(f"The inverse is {result}")
    return result

calculate_inverse(0)


Explanation

When you run this script, it will execute normally until it hits pdb.set_trace(). At that point, execution will pause, and you will see a (Pdb) prompt in your terminal. The program is now waiting for your commands. You can type p value (or just value) to print the current value of the value variable. You will see it is 0. Knowing this, you can identify that the next line, result = 1 / value, will cause a ZeroDivisionError. You can then type q to quit the debugger before the error occurs.

 

Example 2: Stepping Through a Loop

pdb allows you to move through your code line by line to observe changes.

code:

import pdb

def sum_list(items):
    total = 0
    # Set the breakpoint before the loop starts
    pdb.set_trace()
    for item in items:
        total += item
    return total

my_list = [10, 20, -5, 15]
sum_list(my_list)

Explanation

Once the debugger stops at pdb.set_trace(), you can use the following commands:

n (next): Executes the current line and moves to the next line in the same function.

p <variable> (print): Displays the value of a variable.

You can type n to enter the loop. Then, repeatedly type p total and p item followed by n to watch the total accumulate as the loop processes each item in my_list. This is far more interactive than filling your code with print statements.

 

Example 3: Examining Function Calls

pdb provides commands to control how you navigate function calls.

code:

import pdb

def helper_function(a, b):
    # This function has its own logic
    return a + b

def main_function():
    x = 10
    y = 20
    
    # Set a breakpoint before calling the helper function
    pdb.set_trace()
    
    result = helper_function(x, y)
    
    print(f"The final result is {result}")

main_function()

Explanation

When the debugger pauses, you are on the line result = helper_function(x, y). You have several options:

n (next): Executes the entire helper_function in one go and moves to the print statement. You stay out of the function.

s (step): Steps into the helper_function, allowing you to debug the code inside it line by line.

After stepping into a function, you can use r (return) to continue execution until the current function returns.

This control is critical for isolating whether a bug is in the calling code or the function being called.

 

Example 4: Conditional Breakpoints

Sometimes a bug only appears under specific conditions. You can wrap pdb.set_trace() in an if statement to only start debugging when that condition is met.

code:

import pdb

def process_items(items):
    for i, item in enumerate(items):
        # A complex calculation
        processed_item = item * 2 - i
        
        # --- Conditional Breakpoint ---
        # Only break if the processed_item becomes negative
        if processed_item < 0:
            print(f"Problem detected at index {i} with item {item}. Starting debugger.")
            pdb.set_trace() 
        # -----------------------------
            
        print(f"Processed item {i}: {processed_item}")

data = [5, 6, 7, 2, 8]
process_items(data)

Explanation

This code will run without interruption for the first three items. On the fourth item (item is 2, i is 3), the calculation 2 * 2 - 3 results in 1. On the fifth item (item is 8, i is 4), 8 * 2 - 4 is 12. Let's adjust the data to [5, 6, 2, 8] to trigger the bug. When i is 2 and item is 2, processed_item becomes 2 * 2 - 2 = 2. Let's try [5, 6, 7, 2, 1]. For the last item, i=4 and item=1. processed_item = 1 * 2 - 4 = -2. The if condition becomes true, and the debugger starts. This saves you from manually stepping through all the loop iterations that work correctly.

 

Example 5: Post-mortem Debugging

One of pdb's most powerful features is its ability to debug a program after it has crashed with an unhandled exception.

code:

import pdb
import sys

def faulty_division(a, b):
    # This function will crash if b is zero
    result = a / b
    return result

def main():
    try:
        faulty_division(10, 0)
    except Exception:
        # On any exception, enter post-mortem debugging
        print("An exception occurred! Entering post-mortem debugger...")
        # Get the traceback information
        exc_info = sys.exc_info()
        pdb.post_mortem(exc_info[2])

# To run without a try/except, you can run from the command line:
# python -m pdb your_script.py
# Then, when it crashes, you can type 'debug'
main()

Explanation

When faulty_division(10, 0) is called, it raises a ZeroDivisionError. The except block catches this. pdb.post_mortem() then launches the debugger at the exact point where the exception occurred. You can inspect all the variables (a and b) as they were at the moment of the crash. This lets you analyze the state that caused the error without having to predict where the error would happen and set a breakpoint in advance.

 

 

IDE Debugging Tools

Modern Integrated Development Environments (IDEs) like Visual Studio Code, PyCharm, and Spyder provide sophisticated graphical debuggers that build on the concepts of pdb but make them much easier to use. Instead of typing commands, you click buttons to set breakpoints, step through code, and inspect variables in a dedicated user interface.

Note: Using an IDE's debugger is a major productivity boost. Common searches are "how to debug Python in VS Code," "PyCharm debugger tutorial," and "graphical Python debugger."

 

Example 1: Setting a Visual Breakpoint

This is the equivalent of pdb.set_trace(), but done visually.

code:

# A script to demonstrate a visual breakpoint
def main():
    name = "Alice"
    age = 30
    
    # In your IDE, click in the gutter to the left of the next line.
    # A red dot will appear, indicating a breakpoint.
    greeting = f"Hello, {name}! You are {age} years old."
    
    print(greeting)

main()

Explanation

To debug this in an IDE (like VS Code or PyCharm):

Set a breakpoint: Click on the margin next to the greeting = ... line. A red circle will appear.

Run in debug mode: Instead of just running the file, select the "Start Debugging" option (often a green play button with a bug icon, or the F5 key).

The program will execute and pause automatically at your breakpoint. The line will be highlighted. You can now hover your mouse over name and age to see their values, or look in the "Variables" panel of the debugger UI.

 

Example 2: Stepping Over Code in a Loop

Visual debuggers make stepping through loops intuitive.

code:

# A script to demonstrate stepping over lines
def loop_and_print():
    total = 0
    # Set a breakpoint on the 'for' line
    for i in range(1, 5):
        total += i
        print(f"i = {i}, total = {total}")
    return total

loop_and_print()

Explanation

Set a breakpoint on the for i in range(1, 5): line.

Start the debugger. It will pause at the breakpoint.

In the debugger's control panel, find the "Step Over" button (often a curved arrow over a dot).

Each time you click "Step Over," the debugger executes one line of code. You can watch the i and total variables change their values in the "Variables" panel with each click. This gives you a clear, visual trace of the loop's execution.

 

Example 3: Stepping Into and Out of Functions

IDEs provide distinct controls for navigating function calls.

code:

def format_message(name):
    # Set a breakpoint on the next line
    formatted_name = name.strip().capitalize()
    return f"Welcome, {formatted_name}!"

def main():
    user_input = "  bob  "
    # Set a breakpoint on the next line
    message = format_message(user_input)
    print(message)

main()

Explanation

Set a breakpoint on the message = format_message(user_input) line in main().Start the debugger. When it pauses, you have two key choices:

Step Over: Executes the entire format_message() function and pauses on the print(message) line. Use this when you trust format_message to work correctly.

Step Into: Jumps into the format_message() function, pausing at its first line (formatted_name = ...). Use this when you suspect the bug is inside format_message.

If you step into the function, you can then use Step Out to finish executing the rest of the function and return to the line in main() right after the function call.

 

Example 4: Using the Watch Window

The "Watch" panel allows you to monitor specific variables or even expressions throughout the debugging session.

code:

# A script demonstrating the watch window
def complex_calculation(x, y, z):
    # Set a breakpoint here
    a = x * y
    b = a + z
    c = b / x
    
    # In the 'Watch' panel of your IDE, you could add an expression
    # like 'a > 50' or 'c * x' to see its value change.
    
    return c

complex_calculation(10, 5, 20)

Explanation

Set a breakpoint at the start of the function and run the debugger.

Find the "Watch" panel in your IDE's debugger view.

Click "Add Expression" and type in a variable name like a or a full expression like (a + b) > 50.

As you "Step Over" each line of the function, the values in the Watch panel will update automatically. This is extremely powerful for tracking how complex expressions change or checking when a specific condition becomes true.

 

Example 5: Analyzing the Call Stack

The Call Stack shows the chain of function calls that led to the current point in the code. This is invaluable for understanding program flow, especially in complex or recursive code.

code:

# A script to demonstrate the call stack
def func_c(z):
    # Set breakpoint here
    result = z * 2
    return result

def func_b(y):
    result = func_c(y - 5)
    return result

def func_a(x):
    result = func_b(x + 10)
    return result

func_a(5)

Explanation

Set a breakpoint inside the innermost function, func_c.

Run the debugger. When it pauses, find the "Call Stack" panel.

You will see a list that looks like this:

func_c(z=10)

func_b(y=15)

func_a(x=5)

(Module) or (Global Scope)

This shows you that func_a was called, which then called func_b, which finally called func_c. You can click on any function in the stack (e.g., click on func_b) to instantly see the values of its local variables (y was 15) at the moment it called func_c. This is an incredibly effective way to debug nested function calls without needing dozens of print statements.