Debugging Python

Debugging is the systematic process of finding why code behaves unexpectedly, using tracebacks, the standard library's pdb debugger, and stack inspection instead of guesswork.

Debugging is the disciplined process of narrowing down why a program's actual behavior differs from its expected behavior. Python provides rich runtime information for this: detailed tracebacks on uncaught exceptions, the standard library's interactive debugger pdb, the breakpoint() built-in for dropping into that debugger at a chosen point, and post-mortem inspection of a crashed program's final state.

What Debugging Is and Why a Systematic Approach Matters

Debugging is not guessing and re-running; it's forming a hypothesis about what's wrong, gathering evidence to confirm or refute it, and narrowing the search space until the exact cause is found. Python's tools support this directly: a traceback tells you where and how a program failed, and a debugger lets you pause execution and inspect the actual state of variables at that moment, rather than inferring it from print statements alone.

Reading Tracebacks

When an exception propagates uncaught, Python prints a traceback: the chain of function calls (frames) active when the exception was raised, read from the outermost call at the top to the actual failure at the bottom.

Read the last line first for the exception type and message, then work upward through the call stack to see the sequence of calls that led there. Since Python 3.11, tracebacks also underline the exact sub-expression that failed when a line contains multiple operations, which narrows the search further.

def parse_age(text):
    return int(text)

def process_record(record):
    return parse_age(record["age"])

process_record({"age": "not a number"})
Traceback (most recent call last):
  File "app.py", line 6, in <module>
    process_record({"age": "not a number"})
  File "app.py", line 4, in process_record
    return parse_age(record["age"])
  File "app.py", line 2, in parse_age
    return int(text)
ValueError: invalid literal for int() with base 10: 'not a number'

Chained Exceptions

When an exception is raised while handling another exception (either implicitly, or explicitly via raise ... from ...), Python shows both, connected by a message explaining the relationship — this preserves the original root cause instead of hiding it.

try:
    int("bad")
except ValueError as e:
    raise RuntimeError("failed to parse configuration") from e
ValueError: invalid literal for int() with base 10: 'bad'

The above exception was the direct cause of the following exception:

RuntimeError: failed to parse configuration

print Debugging and Its Limits

Sprinkling print() calls to inspect values is quick for tiny scripts but does not scale: it requires editing code, rerunning, and removing prints afterward, and it can't easily inspect state interactively or step through execution. It's a reasonable first tool for a very local, simple question; for anything more involved, a debugger or the logging module (a more permanent, leveled alternative) is more effective.

pdb: The Standard Library Debugger

pdb is Python's built-in interactive debugger, requiring no installation. It can be invoked by running a script under it, by inserting a breakpoint in code, or by dropping into it after a crash.

python -m pdb app.py     # run the whole script under the debugger
import pdb

def compute_total(items):
    pdb.set_trace()          # older, explicit way to pause execution here
    return sum(item.price for item in items)

breakpoint(): The Modern Entry Point

Since Python 3.7, the built-in breakpoint() function is the preferred way to pause execution at a specific line; by default it calls pdb.set_trace(), but it can be redirected (via the PYTHONBREAKPOINT environment variable) to a different debugger, including IDE debuggers, without changing the source code.

def compute_total(items):
    breakpoint()             # drops into pdb (or PYTHONBREAKPOINT's target) here
    return sum(item.price for item in items)
PYTHONBREAKPOINT=0 python app.py   # disable all breakpoint() calls (e.g. for production)

Core pdb Commands

Once inside pdb, a small set of commands cover most debugging sessions.

l (list)      show source around the current line
n (next)      execute the current line, step over function calls
s (step)      execute the current line, step into function calls
c (continue)  resume execution until the next breakpoint or program end
p expr        print the value of an expression (e.g. p items)
pp expr       pretty-print an expression
w (where)     show the current call stack
u / d         move up / down one frame in the call stack
b file:line   set a breakpoint at a location
q (quit)      abort the debugging session

Stack Inspection

The where command (or w) prints the full call stack from where execution currently is, and u/d move between frames — letting you inspect the local variables of a caller, not just the current function, without leaving the debugger.

(Pdb) w
  app.py(10)<module>()
-> process_record(record)
  app.py(6)process_record()
-> return parse_age(record["age"])
> app.py(2)parse_age()
-> return int(text)
(Pdb) u
> app.py(6)process_record()
-> return parse_age(record["age"])
(Pdb) p record
{'age': 'not a number'}

Post-Mortem Debugging

Post-mortem debugging inspects the program's state after it has already crashed, using the traceback that was captured at the moment of failure, rather than requiring you to predict where to set a breakpoint in advance.

pdb.pm() is a shorthand for post_mortem() on the most recently raised exception, handy to call directly after a crash in an interactive session.

python -m pdb -c continue app.py   # run to completion/crash, then drop into pdb post-mortem
import pdb
import traceback

try:
    risky_operation()
except Exception:
    traceback.print_exc()
    pdb.post_mortem()        # inspect state at the point of the exception

IDE Debugging

Most IDEs and editors (VS Code, PyCharm, and others) provide a graphical debugger built on the same underlying concepts — breakpoints, stepping, stack inspection, variable watches — with a visual interface instead of a text prompt. These tools are not part of the standard library; they wrap pdb-like functionality (or their own implementation of the same ideas) behind a UI. The commands differ, but the mental model (pause, inspect, step, resume) is identical to pdb.

Conditional Breakpoints and Logging as a Debugging Aid

pdb supports conditional breakpoints, which only pause execution when a condition is true — useful inside a loop where only one specific iteration is interesting.

For problems that only manifest in production, or intermittently, the logging module (a separate stdlib topic) is often more practical than an interactive debugger, since it records events without pausing execution.

def process_all(records):
    for i, record in enumerate(records):
        if record.get("age") is None:
            breakpoint()      # or: use pdb's 'b line, condition' from the command line
        handle(record)

Practical Usage and Best Practices

Reproduce the bug with the smallest possible input before debugging it. Read the full traceback before touching code — the actual failing line and exception type often make the cause obvious without a debugger at all. Prefer breakpoint() over pdb.set_trace() in new code, since it's shorter and environment-configurable. Remove or guard debugging breakpoints before committing code; an accidental breakpoint() left in production code will pause (and effectively hang) execution waiting for input.

Complete Example

Stepping in confirms count reaches 0 for an empty dataset, pinpointing that average() needs to guard against an empty input before dividing.

def average(numbers):
    total = 0
    count = 0
    for n in numbers:
        total += n
        count += 1
    return total / count

def report(dataset):
    breakpoint()          # pause here to inspect 'dataset' before it's used
    return average(dataset)

report([])                 # will raise ZeroDivisionError inside average()
> app.py(10)report()
-> return average(dataset)
(Pdb) p dataset
[]
(Pdb) s
--Call--
> app.py(1)average()
-> def average(numbers):
(Pdb) n
...
(Pdb) p count
0
(Pdb) c
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero

Mental Model and Quick Reference

A traceback tells you where the program was when it failed; a debugger lets you rewind (via breakpoints or post-mortem) to inspect that moment interactively instead of guessing from static code alone.

breakpoint()            -> pause here, drop into pdb (or configured debugger)
n / s / c                -> step over / step into / continue
p expr / pp expr        -> inspect a value
w / u / d                -> inspect and navigate the call stack
pdb.post_mortem()        -> inspect state after a crash, from its traceback
python -m pdb script.py -> run an entire script under the debugger

Key Takeaways

Read tracebacks from the bottom (exception type and message) and work upward through the call chain to understand the failure.

pdb is the standard library's interactive debugger; breakpoint() (3.7+) is the modern, configurable way to invoke it.

Core pdb commands (n, s, c, p, w, u, d) cover stepping through code and inspecting the call stack and variables.

Post-mortem debugging inspects a program's state at the moment of an already-raised exception, without needing to predict a breakpoint location in advance.

IDE debuggers provide a graphical interface over the same underlying pause/inspect/step model as pdb.

Prefer the smallest possible reproduction and a full read of the traceback before reaching for a debugger.

def process(data): print(data) # left in after debugging, clutters real output return transform(data) import logging logger = logging.getLogger(__name__) def process(data): logger.debug("processing data: %r", data) return transform(data)

Ad hoc print statements left in code pollute output and must be manually removed; the logging module provides a leveled, removable-by-configuration alternative meant to stay in the codebase.

def handler(request): breakpoint() # accidentally committed, hangs any process that reaches this line return build_response(request) def handler(request): return build_response(request) # use breakpoint() locally while debugging, then remove it before committing

A breakpoint() left in committed code pauses execution waiting for interactive input, effectively hanging any non-interactive process (like a production server or CI job) that reaches it.

Debugging by inspection alone misses state that only exists at runtime (actual values, which branch executed); reproducing the failure and inspecting real state is far faster than guessing from source code.

In a Python traceback, where do you find the exception type and message, and in what order should you read the frames above it?

The exception type and message are on the last line. Read the frames above it from bottom to top (the frame nearest the exception first) to trace the sequence of calls that led to the failure, or top to bottom to follow the original call order.

What does breakpoint() do by default, and how can its target debugger be changed without editing source code?

By default it calls pdb.set_trace(), pausing execution and dropping into the pdb debugger at that line. Its target can be changed via the PYTHONBREAKPOINT environment variable (e.g. set to '0' to disable it, or to another debugger's entry point).

What is the difference between the pdb commands 'n' (next) and 's' (step)?

'n' executes the current line and steps over any function calls it makes (staying in the current frame); 's' executes the current line but steps into a function call, moving into that function's frame if the line calls one.

What is post-mortem debugging, and when is it more useful than setting a breakpoint in advance?

It's inspecting a program's state using the traceback captured at the moment an exception was raised, after the fact. It's more useful when you don't know in advance where the failure will occur, since you don't need to predict a breakpoint location.

Why might raise NewError(...) from original_exception be preferable to letting the new exception's cause be implicit or silently discarded?

It explicitly preserves the causal chain in the traceback ('The above exception was the direct cause of...'), so debugging later shows both the original root cause and the new exception, instead of only the final one.

Why is leaving a breakpoint() call in committed production code dangerous?

It pauses execution and waits for interactive debugger input; in a non-interactive context (a production server, a CI pipeline) this effectively hangs the process indefinitely.

What advantage does an IDE's graphical debugger typically share with pdb, despite a different interface?

Both are built on the same core model: pausing execution at a chosen point, inspecting variables and the call stack, and stepping through code line by line or into/over function calls.

What exception is raised, and what would the traceback's last line say?

A ZeroDivisionError is raised when v is 0 inside the list comprehension. The traceback's last line reads: ZeroDivisionError: division by zero, with frames above it showing the call chain through compute() and into divide().

Write a function safe_average(numbers) that returns None for an empty list instead of raising ZeroDivisionError, and explain how you'd have found this bug using pdb if it weren't already obvious from the code.

def safe_average(numbers: list[float]) -> float | None: if not numbers: return None return sum(numbers) / len(numbers)

You'd reproduce the crash with an empty list, note the traceback points at the division line, then either read the code (obvious here) or use breakpoint() just before the division to run p len(numbers) and confirm it's 0, guiding the fix.

Write code that catches a ValueError from int(user_input), re-raises it as a custom ConfigError using 'raise ... from e', and describe what the resulting traceback shows.

class ConfigError(Exception): pass def load_setting(user_input: str) -> int: try: return int(user_input) except ValueError as e: raise ConfigError(f"invalid setting value: {user_input!r}") from e

The traceback shows both exceptions: the original ValueError first, followed by 'The above exception was the direct cause of the following exception:', then the ConfigError — preserving the full causal chain for debugging instead of hiding the root cause.

When this script is run non-interactively (e.g. in an automated test with no attached terminal), what happens at the breakpoint() line?

Execution pauses and pdb attempts to read a command from standard input; with no interactive terminal attached, this typically either hangs waiting for input or raises an error reading from stdin, which is why breakpoint() calls must not be left in code paths that run non-interactively.

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

def compute(values):
    return [divide(100, v) for v in values]

compute([5, 0, 2])
def total(items):
    breakpoint()
    return sum(items)

total([1, 2, 3])