The Debugging Workflow
Debugging is not random trial and error. It is a systematic process of reproducing a bug, locating its source, understanding why it happens, fixing it, and verifying the fix works. Following a structured approach saves time and frustration.
Bugs Are Clues, Not Mysteries
Every bug leaves a trail. Error messages, unexpected outputs, and program behavior all provide clues. Your job is to follow these clues methodically until you find the root cause.
Why it matters: Random changes waste time and can introduce new bugs. Systematic debugging finds the actual cause and fixes it properly.
Debugging Workflow
REPRODUCE
Create minimal test case that triggers the bug
LOCATE
Find exact line where bug occurs
FIX
Change only what needs changing
VERIFY
Test bug is gone AND no new bugs
Reading Stack Traces
When Python raises an exception, it prints a stack trace showing the sequence of function calls that led to the error. Read it from bottom to top.
# Example stack trace
Traceback (most recent call last):
File "main.py", line 10, in
result = calculate(data)
File "main.py", line 6, in calculate
return process(value)
File "main.py", line 3, in process
return 10 / value # ERROR IS HERE
ZeroDivisionError: division by zero
The bottom shows the actual error. Each line above shows where that function was called from. Read bottom-up to trace the execution path.
Types of Bugs
Syntax Error
Invalid Python code
Runtime Error
Crashes during execution
Logic Error
Wrong output, no crash
Edge Case Bug
Fails on unusual inputs
Print Debugging
Print debugging is the simplest technique: add print statements to see what your code is doing. Despite being "old school," it remains highly effective for many bugs. The key is knowing what and where to print.
Basic Print Debugging
def calculate_average(numbers):
print(f"DEBUG: input = {numbers}") # Check input
total = sum(numbers)
print(f"DEBUG: total = {total}") # Check intermediate
count = len(numbers)
print(f"DEBUG: count = {count}") # Check intermediate
result = total / count
print(f"DEBUG: result = {result}") # Check output
return result
Prefix debug prints with "DEBUG:" so you can easily find and remove them later. Print inputs, intermediate values, and outputs.
f-string Debug Mode (Python 3.8+)
# The = specifier prints both variable name and value
x = 42
name = "Alice"
items = [1, 2, 3]
print(f"{x=}") # Output: x=42
print(f"{name=}") # Output: name='Alice'
print(f"{len(items)=}") # Output: len(items)=3
The = specifier in f-strings is a game changer. It automatically prints the expression and its value.
Print Debugging
Quick & SimplePros
- Simple and quick
- Works everywhere
- Good for quick checks
Cons
- Must modify code
- No interactive inspection
pdb Debugger
PowerfulPros
- Interactive exploration
- Step through code
- Inspect any variable
Cons
- Learning curve
- Slower for simple bugs
Practice: Print Debugging
Task: This function should double each number. Add print statements to find why it returns wrong values.
def double_all(numbers):
result = []
for n in numbers:
result.append(n + n) # Bug: should be n * 2
return result
Show Solution
def double_all(numbers):
result = []
for n in numbers:
doubled = n * 2
print(f"{n=}, {doubled=}") # Debug print
result.append(doubled)
print(f"{result=}")
return result
# Bug was n + n for strings concatenates instead of error
Task: This function calculates factorial. Add prints to trace how the result changes each iteration.
def factorial(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
Show Solution
def factorial(n):
print(f"Computing factorial({n})")
result = 1
for i in range(1, n + 1):
result *= i
print(f" i={i}, result={result}")
print(f"Final: {result}")
return result
Task: Add print statements with indentation to trace this recursive function's execution.
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
Show Solution
def fib(n, depth=0):
indent = " " * depth
print(f"{indent}fib({n}) called")
if n <= 1:
print(f"{indent}returning {n}")
return n
result = fib(n-1, depth+1) + fib(n-2, depth+1)
print(f"{indent}fib({n}) = {result}")
return result
The pdb Debugger
Python's built-in debugger, pdb, lets you pause execution, inspect variables, and step through code line by line. It is more powerful than print debugging but requires learning a few commands.
Setting Breakpoints
# Method 1: Insert breakpoint in code
def calculate(x, y):
result = x + y
breakpoint() # Execution pauses here (Python 3.7+)
return result * 2
# Method 2: Using pdb directly
import pdb
pdb.set_trace() # Older method, still works
When Python hits a breakpoint, it opens an interactive prompt. You can inspect variables, run expressions, and control execution.
Essential pdb Commands
next
Execute current line, go to next
step
Step into function call
continue
Run until next breakpoint
print expr
Print value of expression
list
Show source code around current line
quit
Exit debugger
where
Show stack trace
Debugging Session Example
# Example debugging session
(Pdb) p x # Print variable x
42
(Pdb) p y # Print variable y
0
(Pdb) p x / y # Evaluate expression - AHA! Division by zero
*** ZeroDivisionError: division by zero
(Pdb) n # Go to next line
(Pdb) c # Continue execution
Use p to evaluate any Python expression. This helps you understand program state and find the root cause of bugs.
Practice: Using pdb
Task: Add a breakpoint before the return statement to inspect the result.
def greet(name):
greeting = f"Hello, {name}!"
return greeting
Show Solution
def greet(name):
greeting = f"Hello, {name}!"
breakpoint() # Inspect greeting here
return greeting
Task: Add a conditional breakpoint that only triggers when i equals 5.
def find_index(items, target):
for i, item in enumerate(items):
if item == target:
return i
return -1
Show Solution
def find_index(items, target):
for i, item in enumerate(items):
if i == 5:
breakpoint() # Only break at index 5
if item == target:
return i
return -1
Task: Add breakpoints to trace the recursive binary search. When would you use 'step' vs 'next'?
def binary_search(arr, target, lo, hi):
if lo > hi:
return -1
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search(arr, target, mid+1, hi)
else:
return binary_search(arr, target, lo, mid-1)
Show Solution
def binary_search(arr, target, lo, hi):
breakpoint() # Inspect lo, hi, mid each call
if lo > hi:
return -1
mid = (lo + hi) // 2
# Use 'p arr[mid]' and 'p target' to compare
# Use 's' (step) to enter recursive call
# Use 'n' (next) to skip to next line
if arr[mid] == target:
return mid
# ... rest of function
The Logging Module
Unlike print statements, logging can be left in production code. You can control which messages appear based on severity levels and output to files, not just the console. Professional applications use logging instead of print.
Logging Levels
import logging
logging.basicConfig(level=logging.DEBUG)
logging.debug("Detailed info for debugging")
logging.info("General information")
logging.warning("Something unexpected happened")
logging.error("An error occurred")
logging.critical("System is about to crash!")
Set the level to control what appears. DEBUG shows everything, WARNING shows only warnings and above. This lets you leave debug logs in code but hide them in production.
Configuring Log Format
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
filename='app.log' # Write to file instead of console
)
logging.info("Application started")
logging.error("Database connection failed")
Include timestamps, levels, and file names in log format. Write to files to keep a permanent record of what happened.
Practice: Logging
Task: Set up logging at INFO level. Log "Starting process" at start and "Process complete" at end.
Show Solution
import logging
logging.basicConfig(level=logging.INFO)
def process():
logging.info("Starting process")
# ... do work ...
logging.info("Process complete")
process()
Task: Write a function that logs errors with full exception details using logging.exception().
Show Solution
import logging
logging.basicConfig(level=logging.DEBUG)
def divide(a, b):
try:
return a / b
except Exception:
logging.exception("Division failed")
return None
divide(10, 0) # Logs full traceback
Task: Create a custom logger named "myapp" with a file handler that logs to "myapp.log".
Show Solution
import logging
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)
handler = logging.FileHandler("myapp.log")
handler.setLevel(logging.DEBUG)
fmt = logging.Formatter('%(asctime)s - %(name)s - %(message)s')
handler.setFormatter(fmt)
logger.addHandler(handler)
logger.info("Custom logger ready!")
Key Takeaways
Follow the Workflow
Reproduce, locate, fix, verify. Do not skip steps. Random changes create new bugs.
Print Debugging Works
Simple and effective. Use f-strings with = for quick variable inspection.
Learn pdb Basics
breakpoint(), n, s, c, p are enough for most debugging. Use for complex bugs.
Use Logging in Production
Unlike prints, logs can stay in code. Control verbosity with levels.
Read Stack Traces
Read from bottom to top. The last line shows the error, lines above show the path.
Create Minimal Test Cases
Strip away unrelated code to isolate the bug. Smaller code is easier to debug.
Knowledge Check
Quick Quiz
Test what you've learned about Python debugging and logging