Python Basics — Debugging Techniques

5 minute read

Debugging Techniques

Bugs are inevitable. What separates experienced developers from beginners isn’t that they write bug-free code — it’s that they know how to find and fix bugs quickly.

The simplest and most widely used approach:

def calculate_average(scores):
    print(f"DEBUG: scores = {scores}")           # What did we receive?
    total = sum(scores)
    print(f"DEBUG: total = {total}")             # Is the sum correct?
    count = len(scores)
    print(f"DEBUG: count = {count}")             # How many items?
    average = total / count
    print(f"DEBUG: average = {average}")         # Final result
    return average

result = calculate_average([85, 92, 78, 90, 88])

Tips for print() Debugging

# Label your prints clearly
print(f">>> BEFORE loop: items = {items}")
print(f">>> AFTER loop: results = {results}")

# Print types when confused
print(f"type(x) = {type(x)}, value = {x!r}")

# Use repr (!r) to see exact values including hidden characters
text = "hello\n"
print(f"text = {text!r}")  # text = 'hello\n'

# Quick separator for readability
print("=" * 50)
print(f"Processing user: {user}")
print("=" * 50)

When to Remove print() Statements

Always clean up debug prints before committing code. A better approach is to use the logging module (covered below).

Python Debugger (pdb / breakpoint())

The built-in pdb debugger lets you pause execution and inspect your program interactively.

Using breakpoint() (Python 3.7+)

def find_bug(numbers):
    total = 0
    for i, num in enumerate(numbers):
        breakpoint()  # Execution pauses here
        total += num
    return total / len(numbers)

find_bug([10, 20, 30])

When execution hits breakpoint(), you get an interactive prompt:

> /path/to/script.py(5)find_bug()
-> total += num
(Pdb) 

Essential pdb Commands

Command Short What It Does
next n Execute next line (step over)
step s Step into function call
continue c Continue until next breakpoint
print(expr) p expr Print value of expression
list l Show current code context
where w Show call stack
quit q Exit debugger
break N b N Set breakpoint at line N
clear cl Clear breakpoints

pdb Session Example

(Pdb) p total        # Print current total
0
(Pdb) p num          # Print current number
10
(Pdb) p i            # Print loop index
0
(Pdb) n              # Execute next line
(Pdb) p total        # Total after addition
10
(Pdb) c              # Continue to next breakpoint

Setting Breakpoints with pdb

import pdb

def process(data):
    pdb.set_trace()  # Old-style breakpoint (pre-3.7)
    # Or: breakpoint()  # Modern style (3.7+)
    result = transform(data)
    return result

VS Code Debugging

VS Code provides a visual debugger that’s easier to use than pdb:

  1. Set a breakpoint — click the red dot next to a line number
  2. Start debugging — press F5 or click “Run and Debug”
  3. Inspect variables — hover over variables or check the Variables panel
  4. Step through code — use the debug toolbar (Step Over, Step Into, Step Out)

VS Code Debug Controls

Button Key Action
Continue F5 Run until next breakpoint
Step Over F10 Execute line, skip into functions
Step Into F11 Step into function calls
Step Out Shift+F11 Finish current function, return to caller
Restart Ctrl+Shift+F5 Restart debugging
Stop Shift+F5 Stop debugging

Logging Module Basics

The logging module is the professional alternative to print() debugging:

import logging

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

def process_order(order):
    logging.info(f"Processing order #{order['id']}")
    
    if order["total"] > 1000:
        logging.warning(f"Large order: ${order['total']}")
    
    try:
        charge_payment(order)
        logging.info(f"Payment successful for order #{order['id']}")
    except PaymentError as e:
        logging.error(f"Payment failed: {e}")
        raise

Logging Levels

Level When to Use Example
DEBUG Detailed diagnostic info Variable values, flow tracking
INFO Normal operation events “Server started”, “User logged in”
WARNING Something unexpected but handled “Disk 85% full”
ERROR Something failed “Database connection failed”
CRITICAL Program might not continue “Out of memory”

Logging to a File

import logging

logging.basicConfig(
    filename="app.log",
    level=logging.DEBUG,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

logging.info("Application started")
logging.warning("Config file missing, using defaults")
logging.error("Failed to connect to database")

Assert Statements

Use assert to verify assumptions during development:

def calculate_discount(price, percentage):
    assert price >= 0, f"Price cannot be negative: {price}"
    assert 0 <= percentage <= 100, f"Percentage must be 0-100: {percentage}"
    
    discount = price * (percentage / 100)
    final = price - discount
    
    assert final >= 0, "Final price should never be negative"
    return final

# Works fine
print(calculate_discount(100, 20))  # 80.0

# Triggers assertion
calculate_discount(-50, 20)
# AssertionError: Price cannot be negative: -50

Important: assert statements are removed when Python runs with -O (optimize) flag. Never use them for validation of user input — use if/raise instead.

# ❌ Don't use assert for user input validation
assert age > 0, "Age must be positive"  # Removed with python -O

# ✅ Use raise for user input validation
if age <= 0:
    raise ValueError("Age must be positive")

Common Debugging Strategies

1. Reproduce the Bug

# Write a minimal test case that triggers the bug
def test_bug():
    result = buggy_function([1, 2, 3])  # Exact input that fails
    expected = 6
    assert result == expected, f"Got {result}, expected {expected}"

2. Binary Search Debugging

Comment out half the code. If the bug disappears, it’s in the commented half. Keep halving until you find it.

3. Rubber Duck Debugging

Explain your code line by line to someone (or a rubber duck). The act of explaining often reveals the bug.

4. Check the Obvious First

# Common culprits:
# 1. Off-by-one errors
for i in range(len(items)):      # Correct
for i in range(len(items) - 1):  # Missing last item!

# 2. Mutable default arguments
def bad(items=[]):    # ❌ Shared between calls
def good(items=None): # ✅ Fresh list each call
    items = items or []

# 3. Variable shadowing
total = 100
def calc():
    total = total + 1  # UnboundLocalError!
    return total

# 4. Forgetting return
def add(a, b):
    result = a + b
    # forgot: return result  → returns None

5. Read the Error Message Carefully

# The error message usually tells you EXACTLY what's wrong
# TypeError: can only concatenate str (not "int") to str
# → You need str(number) or f-string

# KeyError: 'username'
# → The key 'username' doesn't exist in the dict

# IndentationError: unexpected indent
# → Check your spaces/tabs are consistent

Summary

Technique Best For
print() Quick checks, simple scripts
breakpoint() / pdb Interactive exploration, complex bugs
VS Code Debugger Visual debugging, large projects
logging Production code, long-running programs
assert Development-time sanity checks
  • Start with the error message — it usually points to the problem
  • Use print() for quick debugging, logging for production
  • Learn breakpoint() and pdb — they’ll save you hours
  • Check common mistakes: off-by-one, mutable defaults, missing returns
  • Write minimal test cases that reproduce the bug

Next up: Error Handling Exercises →

Updated: