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
loggingoverprint()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 bareexcept:, so real bugs still surface. - Use
assertto document assumptions during development, but remember Python strips asserts when run with the-Oflag — 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
pdbbut lets you see all local variables at once without typingprepeatedly. - 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 returnsa / b, but catchesZeroDivisionErrorspecifically and logs an error message with theloggingmodule 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 insideadd_item, and use thep id(basket)andcpdb 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
KeyErroron bad input. Usesys.exc_info()to catch it, print the exception type, message, and the line number where it occurred, then returnNoneinstead 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; theloggingmodule is better for anything you intend to keep in the codebase.sys.exc_info()and thetracebackmodule let you inspect an exception’s type, value, and origin without crashing the program.breakpoint()(orimport 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.
