Skip to main content

Python Debugging

Introduction

Debugging is an essential skill for any programmer. It's the process of finding and resolving bugs (errors or problems that prevent correct operation) within computer programs. In Python development, effective debugging can save you hours of frustration and help you build more reliable applications.

In this guide, we'll explore various debugging techniques in Python, from simple print statements to using integrated debugging tools. Whether you're troubleshooting a small script or a complex application, these skills will help you identify and fix issues efficiently.

Understanding Errors in Python

Before diving into debugging techniques, let's understand the common types of errors you might encounter in Python:

1. Syntax Errors

These occur when the Python parser is unable to understand your code due to incorrect syntax.

python
# Syntax error example
if True
print("This will cause an error")

Output:

  File "<stdin>", line 1
if True
^
SyntaxError: expected ':'

2. Runtime Errors (Exceptions)

These occur during program execution. Python provides many built-in exceptions like TypeError, ValueError, IndexError, etc.

python
# Runtime error example
numbers = [1, 2, 3]
print(numbers[5]) # Trying to access index that doesn't exist

Output:

Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range

3. Logical Errors

These are the trickiest bugs, where code runs without raising exceptions but produces incorrect results due to flawed logic.

python
# Logical error example
def calculate_average(numbers):
total = 0
for num in numbers:
total += num
return total # Forgot to divide by length

print(calculate_average([10, 20, 30])) # Returns 60 instead of 20

Basic Debugging Techniques

The simplest debugging technique is to use print() statements to inspect values at different points in your code.

python
def complex_calculation(a, b, c):
print(f"Inputs: a={a}, b={b}, c={c}")

intermediate = a * b
print(f"Intermediate result: {intermediate}")

final_result = intermediate / c
print(f"Final result: {final_result}")

return final_result

result = complex_calculation(10, 5, 2)
print(f"Function returned: {result}")

Output:

Inputs: a=10, b=5, c=2
Intermediate result: 50
Final result: 25.0
Function returned: 25.0

Using assert Statements

The assert statement is useful for checking assumptions during development.

python
def get_positive_number(num):
assert num > 0, f"Expected positive number, got {num}"
return num

# This will work
try:
print(get_positive_number(5))
except AssertionError as e:
print(e)

# This will raise an AssertionError
try:
print(get_positive_number(-5))
except AssertionError as e:
print(e)

Output:

5
Expected positive number, got -5

Logging

For more advanced applications, using the logging module is better than print() statements:

python
import logging

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

def divide_numbers(a, b):
logging.debug(f"Dividing {a} by {b}")
if b == 0:
logging.error("Division by zero!")
return None
result = a / b
logging.info(f"Result: {result}")
return result

divide_numbers(10, 2)
divide_numbers(10, 0)

Output:

2023-11-10 15:30:45,123 - DEBUG - Dividing 10 by 2
2023-11-10 15:30:45,124 - INFO - Result: 5.0
2023-11-10 15:30:45,125 - DEBUG - Dividing 10 by 0
2023-11-10 15:30:45,126 - ERROR - Division by zero!

Python's Built-in Debugger: pdb

Python includes a built-in debugger module called pdb that allows you to set breakpoints, step through code, and inspect variables.

Basic pdb Usage

python
import pdb

def factorial(n):
if n <= 0:
return 1
else:
return n * factorial(n-1)

n = 5
pdb.set_trace() # Set a breakpoint
result = factorial(n)
print(f"The factorial of {n} is {result}")

When you run this code, it will pause at the breakpoint, and you'll enter the interactive debugger. Here are some common pdb commands:

  • n (next): Execute the current line and move to the next line
  • s (step): Step into a function call
  • c (continue): Continue execution until the next breakpoint
  • p expression: Print the value of an expression
  • q (quit): Quit the debugger

Using Breakpoint() Function

In Python 3.7+, you can use the simpler breakpoint() function:

python
def analyze_data(data):
total = sum(data)
average = total / len(data)
breakpoint() # Equivalent to pdb.set_trace()
return {
'total': total,
'average': average,
'max': max(data),
'min': min(data)
}

sample_data = [12, 5, 8, 15, 10]
results = analyze_data(sample_data)
print(results)

Debugging with IDEs

Modern IDEs provide powerful graphical debugging interfaces that make the process much easier.

Visual Studio Code

VS Code with the Python extension offers:

  • Breakpoints that can be set with a click
  • Variable inspection
  • Call stack viewing
  • Watch expressions

Here's how to set up a basic debug configuration in VS Code:

  1. Create a .vscode/launch.json file:
json
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
}
]
}
  1. Set breakpoints by clicking in the gutter next to line numbers
  2. Start debugging using F5 or the Debug menu

PyCharm

PyCharm provides similar features with an even more comprehensive debugging experience:

  • Conditional breakpoints
  • Evaluate expressions
  • Change variable values during debugging

Advanced Debugging Techniques

Using try-except for Debugging

You can use exception handling to identify where errors occur:

python
def risky_operation(data):
try:
result = process_data(data)
return result
except Exception as e:
print(f"Error occurred: {e}")
print(f"Error type: {type(e).__name__}")
import traceback
traceback.print_exc()
return None

def process_data(data):
# This will raise a TypeError if data isn't a list
return [item * 2 for item in data]

# This will work
print(risky_operation([1, 2, 3]))

# This will catch and report the error
print(risky_operation(10))

Post-mortem Debugging

When your program crashes with an exception, you can analyze the state at the time of the crash:

python
import pdb

def faulty_function():
x = 10
y = 0
return x / y # Will raise ZeroDivisionError

try:
faulty_function()
except:
pdb.post_mortem()

This will launch the debugger at the point where the exception occurred.

Using Decorators for Debugging

Creating debugging decorators can help trace function calls:

python
def debug_decorator(func):
def wrapper(*args, **kwargs):
args_repr = [repr(a) for a in args]
kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
signature = ", ".join(args_repr + kwargs_repr)
print(f"Calling {func.__name__}({signature})")

try:
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
except Exception as e:
print(f"{func.__name__} raised {type(e).__name__}: {e}")
raise

return wrapper

@debug_decorator
def divide(a, b):
return a / b

print(divide(10, 5))
try:
print(divide(10, 0))
except:
pass

Output:

Calling divide(10, 5)
divide returned 2.0
2.0
Calling divide(10, 0)
divide raised ZeroDivisionError: division by zero

Real-world Debugging Example

Let's explore a more complex example to demonstrate debugging a real application. We'll create a simple Flask API with a bug, then debug it.

python
from flask import Flask, request, jsonify

app = Flask(__name__)

# Database simulation
users = {
1: {"name": "John", "email": "[email protected]"},
2: {"name": "Sarah", "email": "[email protected]"}
}

@app.route("/user/<int:user_id>")
def get_user(user_id):
# Bug: Not handling case when user doesn't exist
return jsonify(users[user_id])

@app.route("/user", methods=["POST"])
def create_user():
data = request.json

# Debug print
print(f"Received data: {data}")

# Validation
if "name" not in data or "email" not in data:
return jsonify({"error": "Missing required fields"}), 400

# Create new user
user_id = max(users.keys()) + 1
users[user_id] = {"name": data["name"], "email": data["email"]}

# Debug assertion
assert user_id in users, f"User {user_id} was not added properly"

return jsonify({"id": user_id, "name": data["name"]}), 201

if __name__ == "__main__":
app.run(debug=True) # Flask's debug mode

The bug in this application is that /user/<user_id> doesn't handle the case when a user doesn't exist. We can fix it:

python
@app.route("/user/<int:user_id>")
def get_user(user_id):
# Fixed: Handle case when user doesn't exist
if user_id not in users:
return jsonify({"error": "User not found"}), 404
return jsonify(users[user_id])

This example demonstrates:

  1. Using print statements for API debugging
  2. Assertions to catch logical errors
  3. Flask's debug mode for development
  4. Error handling to prevent crashes

Common Debugging Pitfalls and Tips

Pitfalls to Avoid

  1. Debugging without a plan: Randomly changing code can create more bugs
  2. Ignoring error messages: Python's error messages provide valuable clues
  3. Overcomplicating: Start with the simplest explanation for a bug
  4. Not isolating the problem: Create minimal test cases that reproduce the issue

Best Practices

  1. Test incrementally: Write small pieces of code and test frequently
  2. Use version control: So you can revert to working states
  3. Comment your debugging code: So you remember to remove it later
  4. Document bugs and fixes: Create a knowledge base for future reference

Summary

Debugging is a crucial skill that improves with practice. In this guide, we've covered:

  • Different types of Python errors and how to identify them
  • Basic debugging techniques with print statements and asserts
  • Using Python's built-in debugger (pdb)
  • IDE debugging features
  • Advanced techniques like decorators and post-mortem debugging
  • A real-world debugging example
  • Best practices and common pitfalls

Remember that effective debugging is equal parts methodical process and creative problem-solving. The best debuggers are patient, systematic, and persistent.

Additional Resources

  1. Python's official pdb documentation
  2. Python Debugging with VS Code
  3. The Python Debugger Cheat Sheet
  4. Book: "Python Testing with pytest" by Brian Okken

Practice Exercises

  1. Bug Hunt: Take the following code with bugs and fix it:

    python
    def calculate_grades(scores):
    grades = []
    for score in scores:
    if score >= 90:
    grades.append("A")
    elif score >= 80:
    grades.append("B")
    elif score >= 70:
    grades.append("C")
    elif score >= 60:
    grades.append("D")
    else:
    grades.append("F")
    return grade # Bug: undefined variable

    student_scores = [85, 92, 78, 65, 55]
    print(calculate_grades(student_scores))
  2. Debugging Practice: Create a Flask application that reads data from a file. Introduce a bug (like using a non-existent file), then use debugging techniques to identify and fix the issue.

  3. Custom Debugger: Create a custom debugging decorator that logs function execution time and parameters to a file.

Happy debugging!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)