Python Debugging

Debugging is the process of figuring out why a program isn’t doing what you expect, and then fixing it. Every Python developer — beginner or expert — spends a large share of their time debugging: reading error messages, inspecting variables, and tracing the flow of execution through a program. This lesson covers the complete toolkit: print-based debugging, the logging module, inspecting exceptions with sys.exc_info(), and stepping through code interactively with Python’s built-in debugger, pdb.

Overview: How Debugging Works

Bugs generally fall into three categories. Syntax errors are caught before your program even runs, when Python’s parser rejects code that doesn’t follow the language’s grammar. Runtime errors (exceptions) happen while the program is executing — dividing by zero, indexing past the end of a list, calling a method on None — and if nothing catches them, Python prints a traceback and the program stops. Logical errors are the hardest of all: the program runs to completion without crashing, but produces the wrong result, because the code doesn’t actually do what you intended it to do.

Debugging a logical error is fundamentally about narrowing the gap between what you believe the program is doing and what it is actually doing. A reliable workflow looks like this: reproduce the bug consistently, form a hypothesis about which section of code is responsible, inspect the real state of the program at that point (variable values, types, object identities), compare that state to what you expected, and fix the smallest thing that closes the gap — then verify the fix.

To inspect state effectively, it helps to understand how Python executes code. Every function call creates a new frame object, which holds that call’s local namespace (a mapping of variable names to objects) and a reference to the frame that called it. These frames form a call stack. When an exception is raised, Python builds a traceback: a chain of frame references recording every function call that was active at the moment of failure, from the outermost call down to the exact line that raised the exception. Python prints a traceback top to bottom in the order the calls were made, but the cause of the error is almost always on the very last line — read the bottom of a traceback first.

Syntax: The Core Debugging Tools

Python doesn’t force you to use any single debugging approach — you reach for different tools depending on the situation.

Tool Best for
print() Quick, throwaway checks of a variable’s value while iterating on code
logging module Persistent, leveled diagnostic messages you can leave in real (production) code
assert statements Documenting and checking assumptions that should never be false
sys.exc_info() / traceback Programmatically inspecting an exception’s type, value, and originating line
pdb / breakpoint() Interactively pausing execution to step through code line by line

The pdb Command Reference

Once you’re inside a pdb session (the prompt looks like (Pdb)), these are the commands you’ll use most often:

Command Description
l List the source code around the current line
n Execute the current line, stepping over function calls
s Execute the current line, stepping into function calls
c Continue running until the next breakpoint
b <line> Set a breakpoint at the given line number
p <expr> Evaluate and print an expression
pp <expr> Pretty-print an expression (useful for dicts and lists)
w Print the current call stack
q Quit the debugger and abort the program

To enter the debugger from your own code, call the built-in breakpoint() function (Python 3.7+) at the point you want to pause. By default it calls sys.breakpointhook(), which imports pdb and runs pdb.set_trace(). You can redirect or disable this behavior globally with the PYTHONBREAKPOINT environment variable — for example, setting PYTHONBREAKPOINT=0 turns every breakpoint() call into a no-op, which is handy for shipping code that still contains debug breakpoints without them firing in production.

Examples

Example 1: Print-Statement Debugging

The simplest debugging technique is also the most common one: print the values you’re unsure about, right before and after the code that uses them.

def calculate_average(numbers):
    total = 0
    count = 0
    for num in numbers:
        total += num
        count += 1
    return total / count


def process_scores(scores):
    print(f"DEBUG: scores = {scores}")
    average = calculate_average(scores)
    print(f"DEBUG: average = {average}")
    return average


scores = [85, 92, 78, 90, 88]
result = process_scores(scores)
print(f"Final average: {result}")

Output:

DEBUG: scores = [85, 92, 78, 90, 88]
DEBUG: average = 86.6
Final average: 86.6

Adding a DEBUG: prefix makes it easy to find and remove these lines later, and to distinguish them from real program output. Print debugging is fast and requires no setup, but it has real downsides: you have to edit the source to add or remove prints, it clutters output if left in, and it doesn’t scale well once a bug depends on the order of many events.

Example 2: Structured Debugging with the logging Module

The logging module solves print debugging’s biggest problem: you can leave diagnostic statements in your code permanently and simply turn them on or off (and filter by severity) without editing anything.

import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


def divide(a, b):
    logger.debug(f"divide called with a={a}, b={b}")
    try:
        result = a / b
    except ZeroDivisionError:
        logger.error("Attempted division by zero: b cannot be 0")
        return None
    logger.debug(f"divide returning {result}")
    return result


divide(10, 2)
divide(5, 0)

Output:

DEBUG: divide called with a=10, b=2
DEBUG: divide returning 5.0
DEBUG: divide called with a=5, b=0
ERROR: Attempted division by zero: b cannot be 0

Each call to logger.debug() or logger.error() is tagged with a severity level (DEBUG, INFO, WARNING, ERROR, CRITICAL). logging.basicConfig(level=logging.DEBUG, ...) tells the root logger to show every message at DEBUG level or higher; in a deployed application you’d typically set the level to INFO or WARNING so the noisy debug messages disappear without deleting a single line of code.

Example 3: Inspecting an Exception with sys.exc_info()

Sometimes you want to catch an exception, log useful detail about it, and keep going, rather than letting the traceback crash the program. sys.exc_info() gives you programmatic access to the exception that’s currently being handled.

import sys


def risky_operation(data, index):
    return data[index]


def safe_call(data, index):
    try:
        return risky_operation(data, index)
    except IndexError as e:
        exc_type, exc_value, exc_tb = sys.exc_info()
        print(f"Caught {exc_type.__name__}: {exc_value}")
        print(f"Failed at line: {exc_tb.tb_lineno}")
        return None


values = [10, 20, 30]
print(safe_call(values, 1))
print(safe_call(values, 10))

Output:

20
Caught IndexError: list index out of range
Failed at line: 10
None

sys.exc_info() returns a 3-tuple of (type, value, traceback) for the exception currently being handled inside an except block. exc_tb.tb_lineno reports the line number, within the frame that’s handling the exception, where the failing call was made — here, the line inside safe_call that invoked risky_operation. The standard library’s traceback module builds on the same information to format a full, human-readable traceback (via traceback.print_exc() or traceback.format_exc()) even after you’ve caught and are handling the exception yourself.

Example 4: Pausing Execution with breakpoint() and pdb

For a bug you can’t figure out just by reading the code, drop a breakpoint() call exactly where you want to freeze the program and look around:

def calculate_total(prices, tax_rate):
    breakpoint()
    subtotal = sum(prices)
    total = subtotal * (1 + tax_rate)
    return total


total = calculate_total([19.99, 5.50, 12.25], 0.08)
print(f"Total: {total:.2f}")

Running this pauses execution the moment it reaches breakpoint() and opens an interactive (Pdb) prompt in your terminal — no output is printed until you resume. From that prompt you could type p prices and p tax_rate to check the arguments, n twice to step over the next two lines, p subtotal to confirm the running total, and then c to continue to completion, which finally prints Total: 40.76. Because it’s interactive, there’s no fixed transcript to show here — the value of pdb is that you can ask it anything about the live program state, not just what a print statement happened to log in advance.

Under the Hood: How pdb Actually Works

Python’s interpreter can be given a trace function via sys.settrace(), which the interpreter calls every time it enters a new frame, executes a new line, or returns from a frame. pdb is built on top of this mechanism through the standard library’s bdb (base debugger) module: bdb.Bdb installs a trace function that checks, on every line event, whether the current line matches a breakpoint or whether you’re in step mode. When it should stop, it hands control to pdb‘s command loop — built on the cmd.Cmd class — which reads commands like n, s, and p from your terminal and translates them into further trace-function decisions: step to the next line, step into the next call, or resume free running until the next breakpoint. This is also exactly how a graphical debugger in an IDE like VS Code or PyCharm works internally — the same trace-function hook, just driven by a UI instead of a text prompt.

Common Mistakes

Mistake 1: Swallowing Exceptions with a Bare except

A bare except: clause catches everything, including typos that raise NameError and even KeyboardInterrupt, and silently discarding the error makes bugs invisible instead of fixing them:

def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except:
        pass


config = load_config("does_not_exist.txt")
print(f"Config: {config}")

Output:

Config: None

The function failed — the file doesn’t exist — but the program has no idea anything went wrong. Catch the specific exception you expect, and log or handle it deliberately:

import logging

logging.basicConfig(level=logging.ERROR, format="%(levelname)s: %(message)s")


def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        logging.error(f"Config file not found: {e}")
        return None


config = load_config("does_not_exist.txt")
print(f"Config: {config}")

Output:

ERROR: Config file not found: [Errno 2] No such file or directory: 'does_not_exist.txt'
Config: None

Now the failure is visible and diagnosable, and any exception you didn’t anticipate (like a TypeError from a programming mistake elsewhere) will still propagate and produce a real traceback instead of vanishing.

Mistake 2: Mutable Default Arguments

Default argument values are evaluated once, when the function is defined — not once per call. If the default is a mutable object like a list, every call that doesn’t supply its own argument shares the same object:

def add_item(item, basket=[]):
    basket.append(item)
    return basket


print(add_item("apple"))
print(add_item("banana"))

Output:

['apple']
['apple', 'banana']

The second call unexpectedly returns both items, because basket is the exact same list object across every call that relied on the default. The standard fix is to default to None and create a fresh list inside the function body:

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket


print(add_item("apple"))
print(add_item("banana"))

Output:

['apple']
['banana']

This bug is a favorite because it doesn’t crash — it just silently produces wrong results, which is exactly the category of logical error that’s hardest to catch without deliberately inspecting state, for example with p id(basket) in a pdb session.

Best Practices

  • Read tracebacks from the bottom up — the last line names the exception and the message; the line just above it is where it was actually raised.
  • Prefer logging over print() for anything you intend to keep in the codebase; it’s filterable by level and easy to disable without editing source.
  • Catch specific exception types (except ValueError:) instead of bare except:, so real bugs still surface.
  • Use assert to document assumptions during development, but remember Python strips asserts when run with the -O flag — never rely on them for input validation in production.
  • Reproduce the bug with the smallest possible input before debugging — a three-line reproduction is far easier to step through than the full application.
  • Learn your editor or IDE’s graphical debugger; it uses the same mechanism as pdb but lets you see all local variables at once without typing p repeatedly.
  • Remove or guard debug print() statements before committing code — leftover debug output is a common source of noisy logs and confusing diffs.

Practice Exercises

  • Write a function safe_divide(a, b) that returns a / b, but catches ZeroDivisionError specifically and logs an error message with the logging module instead of crashing. Call it with a normal case and a divide-by-zero case and confirm the log output.
  • Take the mutable-default-argument bug from this lesson, insert a breakpoint() call inside add_item, and use the p id(basket) and c pdb commands across two calls to directly observe that the same list object is reused.
  • Write a function that processes a list of dictionaries and deliberately raises a KeyError on bad input. Use sys.exc_info() to catch it, print the exception type, message, and the line number where it occurred, then return None instead of letting the program crash.

Summary

  • Bugs are syntax errors (caught before running), runtime errors/exceptions (caught during execution), or logical errors (the program runs but gives the wrong answer).
  • Debugging is a systematic loop: reproduce, hypothesize, inspect state, compare to expectation, fix, verify.
  • print() is fast for one-off checks; the logging module is better for anything you intend to keep in the codebase.
  • sys.exc_info() and the traceback module let you inspect an exception’s type, value, and origin without crashing the program.
  • breakpoint() (or import pdb; pdb.set_trace()) pauses execution and drops you into an interactive debugger built on the same trace-function mechanism used by IDE debuggers.
  • Always read a traceback from the bottom up, catch specific exceptions rather than using bare except:, and watch out for mutable default arguments.