19  Debugging - Finding and Fixing Code Mysteries

19.1 Chapter Outline

  • Understanding debugging fundamentals
  • Common debugging techniques
  • Using print statements effectively
  • Working with Python’s debugger (pdb)
  • Recognizing common bug patterns
  • Debugging strategies for different error types
  • Preventing bugs through better coding practices

19.2 Learning Objectives

By the end of this chapter, you will be able to: - Understand the debugging mindset and process - Use print statements to inspect your program’s state - Apply systematic debugging techniques to find errors - Recognize and fix common bug patterns - Use Python’s built-in debugging tools - Apply debugging strategies for different types of errors - Develop habits that prevent bugs in your code

19.3 1. Introduction: The Art of Debugging

Every programmer, from beginner to expert, writes code with bugs. Debugging is the process of finding and fixing these errors, and it’s a crucial skill that often separates novice programmers from experienced ones. As software pioneer Brian Kernighan said:

“Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.”

In the previous chapter, we looked at how to handle runtime errors (exceptions) that Python detects and reports. In this chapter, we’ll focus on a more challenging type of error: logical errors where the code runs without crashing but doesn’t produce the expected results.

AI Tip: When you’re stuck on a bug, explain your code line by line to your AI assistant. The process of explaining often helps you spot the issue yourself, a technique known as “rubber duck debugging.”

19.4 2. Understanding Debugging Fundamentals

19.4.1 Types of Errors Revisited

As a reminder, there are three main types of errors in programming:

  1. Syntax Errors: Code doesn’t follow language rules (Python catches these automatically)
  2. Runtime Errors/Exceptions: Code runs but fails during execution (covered in Chapter 16)
  3. Logical Errors/Bugs: Code runs without errors but produces incorrect results

Debugging primarily focuses on the third type, which is the most challenging. These errors don’t trigger exceptions but produce unexpected or incorrect behaviors.

19.4.2 The Debugging Mindset

Effective debugging requires a particular mindset:

  • Be systematic: Follow a methodical approach rather than making random changes
  • Be curious: Ask “why” repeatedly to get to the root cause
  • Be patient: Some bugs take time to find and fix
  • Be scientific: Form hypotheses, test them, and analyze results
  • Be persistent: Don’t give up when the solution isn’t immediately obvious

19.4.3 The Debugging Process

A systematic debugging process typically follows these steps:

  1. Reproduce the bug: Find reliable steps to make the problem occur
  2. Isolate the problem: Narrow down where the bug might be
  3. Inspect the state: Examine variables and program flow
  4. Form a hypothesis: Make an educated guess about the cause
  5. Test the fix: Apply a solution and verify it works
  6. Review the code: Look for similar issues elsewhere in your code

19.5 3. The Print Statement: Your First Debugging Tool

The simplest and often most effective debugging technique is using print statements to see what’s happening in your code:

def calculate_average(numbers):
    print(f"Input to calculate_average: {numbers}")
    total = sum(numbers)
    print(f"Sum of numbers: {total}")
    average = total / len(numbers)
    print(f"Calculated average: {average}")
    return average

# Bug: This will return the wrong average
scores = [85, 90, 78]
avg = calculate_average(scores)
print(f"Average score: {avg}")

Strategic print statements can reveal: - Input values (what data is the function receiving?) - Intermediate values (what calculations are happening?) - Output values (what is being returned?)

19.5.1 Enhancing Print Statements

Make your print statements more useful by:

# Include context in your print messages
print(f"DEBUG - calculate_average() - received input: {numbers}")

# Use visual separators for important information
print("="*50)
print("CRITICAL VALUE:", result)
print("="*50)

# Print variable types when values look correct but operations fail
print(f"Value: {value}, Type: {type(value)}")

19.5.2 Temporary Debugging Code

Remember to remove or comment out debugging print statements when you’re done:

def calculate_average(numbers):
    # DEBUG print(f"Input: {numbers}")
    total = sum(numbers)
    # DEBUG print(f"Sum: {total}")
    average = total / len(numbers)
    return average

Adding # DEBUG makes it easier to find and remove these statements later.

19.6 4. Debugging with Python’s Built-in Tools

19.6.1 The pdb Module

Python includes a built-in debugger called pdb (Python DeBugger) that lets you pause code execution and inspect variables:

import pdb

def calculate_average(numbers):
    total = sum(numbers)
    pdb.set_trace()  # Code execution pauses here
    average = total / len(numbers)
    return average

scores = [85, 90, 78]
avg = calculate_average(scores)

When the set_trace() function runs, the program pauses and gives you a special prompt where you can: - Inspect variable values - Execute Python statements - Step through the code line by line - Continue execution

19.6.2 Common pdb Commands

In the debugger prompt, you can use: - p variable_name - Print a variable’s value - n - Execute the next line (step over) - s - Step into a function call - c - Continue execution until the next breakpoint - q - Quit the debugger - h - Help on debugger commands

19.6.3 Using Breakpoints in Python 3.7+

In newer Python versions, you can use a simpler breakpoint function:

def calculate_average(numbers):
    total = sum(numbers)
    breakpoint()  # Equivalent to pdb.set_trace()
    average = total / len(numbers)
    return average

19.7 5. Common Bug Patterns and How to Find Them

19.7.1 Off-by-One Errors

These occur when your loop iterates one too many or too few times:

# Bug: This only processes the first n-1 items
def process_items(items):
    for i in range(len(items) - 1):  # Should be range(len(items))
        process_item(items[i])

Debugging Tip: Print loop indices and boundary values to check iteration ranges.

19.7.2 Type Mismatches

These bugs happen when a value’s type is different from what you expect:

# Bug: user_age from input() is a string, not an integer
user_age = input("Enter your age: ")
years_until_retirement = 65 - user_age  # TypeError: can't subtract string

Debugging Tip: Print both the value and type of suspicious variables, e.g., print(f"user_age: {user_age}, type: {type(user_age)}").

19.7.3 Logic Errors

Errors in the code’s logic that give incorrect results:

# Bug: Logic error in calculating average
scores = [85, 90, 78]
average = sum(scores) / 4  # Should divide by len(scores), which is 3

Debugging Tip: Break complex expressions into smaller parts and print each part.

19.7.4 Missing Initialization

Failing to initialize a variable before using it:

# Bug: total is not initialized before the loop
# total = 0  # This line is missing
for num in numbers:
    total += num  # NameError: name 'total' is not defined

Debugging Tip: Use print statements at the beginning of functions to verify variable initialization.

19.7.5 Scope Issues

Using variables from the wrong scope:

def calculate_total(items):
    # Bug: Trying to access a global variable that doesn't exist
    # or using a variable before defining it
    return items_count * average_price  # NameError

Debugging Tip: Print all variables used in a calculation to verify they exist in the current scope.

19.8 6. Debugging Strategies for Different Error Types

19.8.1 Strategy for Logical Errors

When your code runs but gives incorrect results:

  1. Add print statements at key points to track variable values
  2. Compare expected vs. actual values at each step
  3. Check boundary conditions (first iteration, last iteration, empty collections)
  4. Break down complex expressions into simpler parts
  5. Test with simple inputs where you can calculate the correct result by hand

19.8.2 Strategy for Intermittent Bugs

When bugs only appear sometimes:

  1. Look for race conditions or timing issues
  2. Check for random inputs or behaviors
  3. Search for hidden dependencies on external factors
  4. Add extensive logging to capture the state when the bug occurs
  5. Try to make the bug reproducible with specific inputs

19.8.3 Strategy for “It Worked Yesterday” Bugs

When code that used to work suddenly breaks:

  1. Review recent changes to the code
  2. Check for changes in dependencies or external resources
  3. Verify input data hasn’t changed
  4. Roll back changes one by one to find the breaking change
  5. Look for environmental differences between systems

19.9 7. Debugging in Practice: A Real Example

Let’s debug a function with a problem:

def find_highest_scorer(student_scores):
    highest_score = 0
    highest_scorer = None

    for student, score in student_scores.items():
        if score > highest_score:
            highest_score = score
            highest_scorer = student

    return highest_scorer

# Test case
scores = {"Alice": 85, "Bob": 92, "Charlie": 78, "Diana": -5}
top_student = find_highest_scorer(scores)
print(f"The highest scorer is {top_student}")  # Should be "Bob"

If we add a student with a negative score, we need to handle that case:

# Bug: If all scores are negative, this function fails
scores = {"Alice": -10, "Bob": -5, "Charlie": -20}
top_student = find_highest_scorer(scores)
print(f"The highest scorer is {top_student}")  # Should be "Bob" but returns None

19.9.1 Debugging the Example

Let’s add print statements to investigate:

def find_highest_scorer(student_scores):
    print(f"Scores received: {student_scores}")
    highest_score = 0  # Bug is here - this should be initialized differently
    highest_scorer = None

    for student, score in student_scores.items():
        print(f"Checking {student} with score {score}")
        print(f"Current highest: {highest_score} by {highest_scorer}")
        if score > highest_score:
            print(f"New highest score found: {score}")
            highest_score = score
            highest_scorer = student

    print(f"Final highest scorer: {highest_scorer} with {highest_score}")
    return highest_scorer

The output reveals our bug:

Scores received: {'Alice': -10, 'Bob': -5, 'Charlie': -20}
Checking Alice with score -10
Current highest: 0 by None
Checking Bob with score -5
Current highest: 0 by None
Checking Charlie with score -20
Current highest: 0 by None
Final highest scorer: None with 0

The issue is that we initialized highest_score to 0, but all scores are negative, so none pass the score > highest_score check. Here’s the fix:

def find_highest_scorer(student_scores):
    if not student_scores:
        return None

    # Initialize with the first student's score
    students = list(student_scores.keys())
    highest_scorer = students[0]
    highest_score = student_scores[highest_scorer]

    for student, score in student_scores.items():
        if score > highest_score:
            highest_score = score
            highest_scorer = student

    return highest_scorer

19.10 8. Self-Assessment Quiz

  1. What is the primary difference between debugging and exception handling?
    1. Debugging is for syntax errors; exception handling is for runtime errors
    2. Debugging is for finding errors; exception handling is for responding to known errors
    3. Debugging is a development activity; exception handling is a runtime activity
    4. All of the above
  2. Which of these is NOT a common debugging technique?
    1. Adding print statements
    2. Using a debugger like pdb
    3. Adding try/except blocks
    4. Rubber duck debugging (explaining code to an inanimate object)
  3. In the Python debugger (pdb), which command continues execution until the next breakpoint?
    1. n
    2. s
    3. c
    4. r
  4. What is an “off-by-one” error?
    1. A mathematical error where calculations are off by one unit
    2. A loop iteration error where the loop runs one too many or too few times
    3. An indexing error where you access the wrong element in a sequence
    4. All of the above
  5. What’s the best first step when encountering a bug in your code?
    1. Immediately start changing code to try to fix it
    2. Reproduce the bug with a simple, reliable test case
    3. Add print statements everywhere
    4. Ask someone else to fix it

Answers & Feedback: 1. d) All of the above — Debugging and exception handling serve different purposes and occur at different times 2. c) Adding try/except blocks — This is error handling, not debugging 3. c) c — This continues execution until a breakpoint or the program ends 4. d) All of the above — Off-by-one errors can manifest in various ways 5. b) Reproduce the bug with a simple, reliable test case — Always start by making sure you can reliably recreate the issue

19.11 9. Debugging Tools Beyond Print Statements

19.11.1 Logging

For more sophisticated debugging, use Python’s logging module:

import logging

# Configure logging
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    filename='debug.log'
)

def calculate_average(numbers):
    logging.debug(f"Calculate_average called with {numbers}")
    if not numbers:
        logging.warning("Empty list provided, returning 0")
        return 0

    total = sum(numbers)
    logging.debug(f"Sum calculated: {total}")
    average = total / len(numbers)
    logging.debug(f"Average calculated: {average}")
    return average

Advantages of logging over print statements: - Log to a file instead of the console - Use different severity levels (DEBUG, INFO, WARNING, ERROR, CRITICAL) - Include timestamps and other metadata - Can be enabled/disabled without removing code

19.11.2 Assertions

Use assertions to verify assumptions in your code:

def calculate_average(numbers):
    assert len(numbers) > 0, "Cannot calculate average of empty list"
    total = sum(numbers)
    average = total / len(numbers)
    return average

If the condition after assert is False, Python raises an AssertionError with the provided message.

19.12 Project Corner: Debugging Your Chatbot

Let’s apply debugging techniques to enhance our chatbot’s reliability:

import random
import logging
import datetime
import os

# Configure logging
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(levelname)s - %(message)s',
    filename='chatbot_debug.log'
)

# Response patterns
response_patterns = {
    "greetings": ["hello", "hi", "hey", "howdy", "hola"],
    "farewells": ["bye", "goodbye", "see you", "cya", "farewell"],
    "gratitude": ["thanks", "thank you", "appreciate"],
    "bot_questions": ["who are you", "what are you", "your name"],
    "user_questions": ["how are you", "what's up", "how do you feel"]
}

response_templates = {
    "greetings": ["Hello there!", "Hi! Nice to chat with you!"],
    "farewells": ["Goodbye! Come back soon!", "See you later!"],
    "gratitude": ["You're welcome!", "Happy to help!"],
    "bot_questions": ["I'm PyBot, a simple chatbot built with Python!"],
    "user_questions": ["I'm functioning well, thanks for asking!"]
}

class DebugChatbot:
    """A chatbot with enhanced debugging capabilities."""

    def __init__(self, name="PyBot"):
        self.name = name
        self.user_name = None
        self.conversation_history = []
        self.response_patterns = response_patterns
        self.response_templates = response_templates
        self.debug_mode = False
        logging.info(f"Chatbot {name} initialized")

    def toggle_debug(self):
        """Toggle debug mode on/off."""
        self.debug_mode = not self.debug_mode
        status = "ON" if self.debug_mode else "OFF"
        logging.info(f"Debug mode turned {status}")
        return f"Debug mode is now {status}"

    def debug_print(self, message):
        """Print debug messages if debug mode is on."""
        if self.debug_mode:
            print(f"DEBUG: {message}")
        logging.debug(message)

    def get_response(self, user_input):
        """Generate a response with debugging information."""
        self.debug_print(f"Processing input: '{user_input}'")

        if not user_input:
            self.debug_print("Empty input received")
            return "I didn't catch that. Could you please say something?"

        user_input = user_input.lower()
        self.debug_print(f"Lowercase input: '{user_input}'")

        # Check if this is a debug command
        if user_input == "debug":
            return self.toggle_debug()

        # Check each category of responses
        for category, patterns in self.response_patterns.items():
            self.debug_print(f"Checking category: {category}")

            for pattern in patterns:
                if pattern in user_input:
                    self.debug_print(f"Pattern match found: '{pattern}'")

                    # Get response templates for this category
                    templates = self.response_templates.get(category)
                    self.debug_print(f"Found {len(templates)} possible responses")

                    # Select a random response
                    response = random.choice(templates)
                    self.debug_print(f"Selected response: '{response}'")
                    return response

        # No pattern matched
        self.debug_print("No pattern matches found")
        return "I'm still learning. Can you tell me more?"

    def run(self):
        """Run the chatbot with error tracing."""
        try:
            print(f"Hello! I'm {self.name}. Type 'bye' to exit or 'debug' to toggle debug mode.")
            self.user_name = input("What's your name? ")
            logging.info(f"User identified as {self.user_name}")
            print(f"Nice to meet you, {self.user_name}!")

            self.add_to_history(self.name, f"Nice to meet you, {self.user_name}!")

            while True:
                try:
                    user_input = input(f"{self.user_name}> ")
                    self.add_to_history(self.user_name, user_input)

                    if user_input.lower() in ["bye", "goodbye", "exit"]:
                        response = f"Goodbye, {self.user_name}!"
                        print(f"{self.name}> {response}")
                        self.add_to_history(self.name, response)
                        break

                    response = self.get_response(user_input)
                    print(f"{self.name}> {response}")
                    self.add_to_history(self.name, response)

                except Exception as e:
                    error_msg = f"Error in conversation loop: {str(e)}"
                    logging.error(error_msg, exc_info=True)
                    if self.debug_mode:
                        print(f"DEBUG ERROR: {error_msg}")
                    print(f"{self.name}> Sorry, I encountered a problem. Let's continue.")

        except Exception as e:
            logging.critical(f"Critical error in chatbot: {str(e)}", exc_info=True)
            print(f"Critical error: {str(e)}")
            print("Check the log file for details.")

    def add_to_history(self, speaker, text):
        """Add a message to conversation history with timestamp."""
        timestamp = datetime.datetime.now().strftime("%H:%M:%S")
        entry = {
            "speaker": speaker,
            "text": text,
            "timestamp": timestamp
        }
        self.conversation_history.append(entry)
        self.debug_print(f"Added to history: {entry}")

# Create and run the chatbot
if __name__ == "__main__":
    chatbot = DebugChatbot()
    chatbot.run()

This enhanced chatbot includes:

  1. Logging: Records detailed information for later analysis
  2. Debug Mode: Toggleable detailed output with the “debug” command
  3. Error Handling: Catches and logs exceptions without crashing
  4. Detailed Tracing: Tracks the processing of each user input
  5. Structured History: Stores conversations with timestamps

Debugging Challenges: - Add assertions to verify the integrity of the conversation history - Implement a “replay” command that shows the exact steps of how a response was generated - Create a “why” command that explains why the bot gave a particular response - Add more detailed logging for file operations - Create a visual representation of the chatbot’s decision tree

19.13 Cross-References

AI Tip: When debugging, describe your expectations, what actually happened, and the code you’re working with to your AI assistant. It can often spot patterns and suggest debugging approaches you might not have considered.

19.14 Preventing Bugs: The Best Debugging is No Debugging

While debugging skills are essential, preventing bugs in the first place is even better:

19.14.1 Write Clear, Simple Code

The more complex your code, the more places for bugs to hide:

# Hard to debug
result = sum([x for x in data if x > threshold]) / len([y for y in data if y > 0])

# Easier to debug - break it down
valid_values = [x for x in data if x > threshold]
total = sum(valid_values)
positive_count = len([y for y in data if y > 0])
result = total / positive_count

19.14.2 Document Your Assumptions

Make implicit assumptions explicit through comments and assertions:

def calculate_average(numbers):
    """Calculate the average of a list of numbers.

    Args:
        numbers: A non-empty list of numeric values

    Returns:
        The arithmetic mean of the numbers

    Raises:
        ZeroDivisionError: If the input list is empty
    """
    # Assumption: numbers is a non-empty list
    assert len(numbers) > 0, "numbers list cannot be empty"

    return sum(numbers) / len(numbers)

19.14.3 Write Tests

Testing (covered in the next chapter) helps you catch bugs early:

def test_calculate_average():
    assert calculate_average([1, 2, 3]) == 2
    assert calculate_average([0, 0, 0]) == 0
    assert calculate_average([-1, 1]) == 0
    # Test edge cases too
    assert calculate_average([1000000]) == 1000000

19.14.4 Use Consistent Conventions

Consistent code style reduces confusion and errors:

# Consistent naming makes code more predictable
# Variables in snake_case
user_name = "Alice"
total_amount = 100

# Constants in UPPERCASE
MAX_ATTEMPTS = 3
DEFAULT_TIMEOUT = 30

# Functions in snake_case
def calculate_total(items):
    pass

# Classes in CamelCase
class UserAccount:
    pass

By combining effective debugging techniques with preventative practices, you’ll find and fix bugs faster—and create fewer of them in the first place. Remember that debugging is a skill that improves with practice, so don’t get discouraged when you encounter challenging bugs. Each one you solve makes you a better programmer.