Debugging Python Code

Debugging Python code means finding the cause of incorrect output, exceptions, unexpected control flow, performance problems, or invalid data. A useful debugging process starts by reproducing the problem, reading the traceback, inspecting the program state, and testing one assumption at a time.

This tutorial covers practical Python debugging techniques, including temporary print statements, custom debugging helpers, traceback analysis, the built-in pdb debugger, command-line debugging, and debugging Python code in Visual Studio Code.

A Practical Python Debugging Workflow

Before changing the code, reduce the problem to a repeatable case. A consistent debugging workflow prevents random edits from hiding the original issue.

  1. Reproduce the failure with the same input and environment.
  2. Read the complete traceback from the final line upward.
  3. Identify the first line in your own code that contributed to the error.
  4. Inspect the values and types used on that line.
  5. Form one specific explanation for the failure.
  6. Test that explanation with a print statement, assertion, log message, or breakpoint.
  7. Apply the smallest correction that fixes the underlying cause.
  8. Run the original case and nearby edge cases again.

When a program does not raise an exception but returns the wrong result, compare the actual program state with the expected state at important points in the calculation.

Debugging Python with Print Statements

Using print() is one of the quickest ways to inspect a small Python program. It is useful when you need to confirm whether a branch runs, inspect an intermediate value, or check the type of data received by a function.

For example, the following program calculates a discounted price but produces an unexpected result because the discount is supplied as a whole number rather than a decimal fraction.

</>
Copy
def calculate_price(price, discount):
    print(f"price={price!r}, type={type(price).__name__}")
    print(f"discount={discount!r}, type={type(discount).__name__}")

    final_price = price - (price * discount)
    print(f"final_price={final_price!r}")
    return final_price

calculate_price(100, 20)

The output makes the incorrect assumption visible: the function receives 20, but its formula expects a value such as 0.20.

price=100, type=int
discount=20, type=int
final_price=-1900

Useful details to print include the variable name, its representation, its type, the current loop index, and identifiers that help distinguish one request or record from another. The !r conversion in an f-string uses repr(), which can expose hidden whitespace and escape characters.

Temporary prints become difficult to manage in larger applications. For long-running programs, web applications, or services, use the logging module so messages can include severity levels, timestamps, and destination configuration.

Debugging Python with a Custom Helper Function

Having a custom function in a snippet that you can quickly grab and paste into the code and then use to debug can be very useful. The important thing is to code it in a way that it won’t leave stuff around when you eventually remove the calls and its definition; therefore it’s important to code it in a way that is completely self-contained.

Another good reason for this requirement is that it will avoid potential name clashes with the rest of the code. Here’s an example of such a function—custom.py:

</>
Copy
def debug(*msg, print_separator=True):
    print(*msg)
    if print_separator:
        print('-' * 40)

debug('Data is ...')
debug('Different', 'Strings', 'Are not a problem')
debug('After while loop', print_separator=False)

In this case, a keyword-only argument is used to be able to print a separator, which is a line of 40 dashes.

The function is very simple, you redirect whatever is in msg to a call to print and, if print_separator is True, you print a line separator. Running the code will show the following:

$ python custom.py 
Data is ...
----------------------------------------
Different Strings Are not a problem
----------------------------------------
After while loop

As you can see, there is no separator after the last line. This is just one easy way to somehow augment a simple call to the print function. Now, see how you can calculate a time difference between calls, using one of Python’s tricky features to your advantage—custom_timestamp.py:

</>
Copy
from time import sleep

def debug(*msg, timestamp=[None]):
    print(*msg)
    from time import time  # local import
    if timestamp[0] is None:
        timestamp[0] = time()  #1
    else:
        now = time()
        print(' Time elapsed: {:.3f}s'.format(
            now - timestamp[0]))
        timestamp[0] = now  #2

debug('Entering nasty piece of code...')
sleep(.3)
debug('First step done.')
sleep(.5)
debug('Second step done.')

This is a bit trickier, but still quite simple. First, you import the time function from the time module in the debug function. This allows you to avoid having to add that import outside of the function and maybe forget it there.

Take a look at how timestamp is defined. It’s a list, but it is also a mutable object. This means that it will be set up when Python parses the function, and it will retain its value throughout different calls. Therefore, if you put a timestamp in it after each call, you can keep track of time without having to use an external global variable.

After printing the message you want to print and the importing time, you can then inspect the content of the only item in timestamp. If it is None, you have no previous reference, therefore you set the value to the current time (#1).

On the other hand, if you have a previous reference, you can calculate the difference and insert the current time again in timestamp (#2). Running this code shows this result:

$ python custom_timestamp.py 
Entering nasty piece of code...
First step done.
 Time elapsed: 0.300s
Second step done.
 Time elapsed: 0.501s

This example depends on the persistent state of a mutable default argument. Although it demonstrates that behavior, mutable default arguments can cause accidental state sharing in normal application code. A clearer debugging timer can store its state in an object or closure.

Using Python Logging Instead of Repeated Print Calls

The standard logging module is better suited to applications where debugging messages need to remain available without always appearing in normal output.

</>
Copy
import logging

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

logger = logging.getLogger(__name__)


def divide_total(total, count):
    logger.debug("Calculating average: total=%r, count=%r", total, count)
    return total / count


print(divide_total(120, 4))

Use logger.debug() for detailed development information, logger.info() for normal operational events, logger.warning() for recoverable concerns, and logger.exception() inside an exception handler when the traceback should be recorded.

Reading a Python Traceback

A traceback shows the sequence of function calls active when an unhandled exception occurred. Start with the final line, which identifies the exception type and message. Then move upward to locate the relevant line in your own code.

Here’s a very small example—traceback_simple.py:

</>
Copy
d = {'some': 'key'}
key = 'some-other'
print(d[key])

You have a dict and you tried to access a key which isn’t in it. You should remember that this will raise a KeyError exception. Now run the code:

$ python traceback_simple.py 
Traceback (most recent call last):
  File "traceback_simple.py", line 3, in <module>
    print(d[key])
KeyError: 'some-other'

The final line identifies a KeyError for 'some-other'. The frame immediately above it identifies the filename, line number, and expression that attempted the invalid dictionary lookup.

When a traceback contains many frames from libraries or frameworks, find the deepest frame that belongs to your project. That location is often where invalid data entered a library call, even when the final exception was raised elsewhere.

Debugging Chained Exceptions in Python

Python preserves exception context when one exception is raised while another is being handled. This helps distinguish the original technical failure from the higher-level error presented by an application.

Imagine that you’re validating a dict, working on mandatory fields; therefore you expect them to be there. If not, you need to raise a custom ValidationError that you’ll trap further upstream in the process that runs the validator. It should be something like this—traceback_validator.py:

</>
Copy
class ValidatorError(Exception):
    """Raised when accessing a dict results in KeyError. """

d = {'some': 'key'}
mandatory_key = 'some-other'
try:
    print(d[mandatory_key])
except KeyError:
    raise ValidatorError(
        '`{}` not found in d.'.format(mandatory_key))

You need to define a custom exception that is raised when the mandatory key isn’t there. Note that its body consists of its documentation string, so you don’t need to add any other statements.

Simply put, you define a dummy dict and try to access it using mandatory_key. You trap KeyError and raise ValidatorError when that happens. This allows calling code to handle one application-specific exception type while the traceback retains information about the original dictionary lookup failure.

The code produces this result:

$ python traceback_validator.py 
Traceback (most recent call last):
  File "traceback_validator.py", line 7, in <module>
    print(d[mandatory_key])
KeyError: 'some-other'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "traceback_validator.py", line 10, in <module>
    '`{}` not found in d.'.format(mandatory_key))
__main__.ValidatorError: `some-other` not found in d.

The traceback shows both the initial KeyError and the later ValidatorError. For clearer and more intentional chaining, Python also supports raise ... from ....

</>
Copy
class ValidatorError(Exception):
    pass


def require_key(data, key):
    try:
        return data[key]
    except KeyError as error:
        raise ValidatorError(f"Required key {key!r} is missing") from error

Using Assertions to Check Python Program Assumptions

An assertion is useful for checking an internal condition that should always be true during development. When the condition is false, Python raises AssertionError close to the point where the invalid state becomes visible.

</>
Copy
def calculate_average(total, count):
    assert count > 0, "count must be greater than zero"
    return total / count

Assertions should not replace validation for user input, API payloads, permissions, or other conditions that can legitimately fail at runtime. Python can disable assertions when run with optimization, so externally supplied data should be validated with normal conditional checks and exceptions.

Using the Built-In Python Debugger

Python includes the pdb interactive debugger in its standard library. It can pause a running program, inspect variables, evaluate expressions, move through the call stack, and execute code one statement at a time.

Modern Python versions provide the built-in breakpoint() function. Add it where execution should pause:

</>
Copy
def calculate_total(prices):
    subtotal = sum(prices)
    breakpoint()
    tax = subtotal * 0.18
    return subtotal + tax


print(calculate_total([100, 250, 50]))

Run the file normally. When execution reaches breakpoint(), the debugger prompt appears in the terminal.

Common pdb Commands for Python Debugging

CommandPurpose
nRun the current line and stop at the next line in the same function.
sStep into a function called by the current line.
cContinue running until the next breakpoint or exception.
p expressionEvaluate and print an expression.
pp expressionPretty-print an expression.
lList source code around the current line.
wDisplay the current call stack.
uMove one frame up the call stack.
dMove one frame down the call stack.
bList breakpoints or create a breakpoint.
qQuit the debugger.
hShow debugger help.

Another effective way of debugging Python is to use the Python debugger: pdb. If you use the IPython console, the third-party ipdb package provides a debugger interface integrated with IPython features.

As a toy example, pretend you have a parser that is raising a KeyError because a key is missing in a dict. The dict is from a JSON payload that you can’t control, and you want, for the time being, to inspect what happens afterward. See how you could intercept this moment, inspect the data, modify it temporarily, and continue execution with ipdb—ipdebugger.py:

</>
Copy
# d comes from a JSON payload we don't control
d = {'first': 'v1', 'second': 'v2', 'fourth': 'v4'}
# keys also comes from a JSON payload we don't control
keys = ('first', 'second', 'third', 'fourth')

def do_something_with_value(value):
    print(value)

for key in keys:
    do_something_with_value(d[key])

print('Validation done.')

This code breaks when key gets the value third, which is missing in the dictionary. If you run the code as it is, you’ll get the following:

$ python ipdebugger.py 
v1
v2
Traceback (most recent call last):
  File "ipdebugger.py", line 10, in <module>
    do_something_with_value(d[key])
KeyError: 'third'

Now inject a call to ipdb—ipdebugger_ipdb.py:

</>
Copy
# d comes from a JSON payload we don't control
d = {'first': 'v1', 'second': 'v2', 'fourth': 'v4'}
# keys also comes from a JSON payload we don't control
keys = ('first', 'second', 'third', 'fourth')

def do_something_with_value(value):
    print(value)

import ipdb
ipdb.set_trace()  # we place a breakpoint here

for key in keys:
    do_something_with_value(d[key])

print('Validation done.')

If you now run this code, the debugger pauses before the loop:

$ python ipdebugger_ipdb.py
> /home/fab/srv/l.p/ch11/ipdebugger_ipdb.py(12)<module>()
     11 
---> 12 for key in keys:  # this is where the breakpoint comes
     13     do_something_with_value(d[key])

ipdb> keys  # let's inspect the keys tuple
('first', 'second', 'third', 'fourth')
ipdb> !d.keys()  # now the keys of d
dict_keys(['first', 'fourth', 'second'])  # we miss 'third'
ipdb> !d['third'] = 'something dark side...'  # let's put it in
ipdb> c  # ... and continue
v1
v2
something dark side...
v4
Validation done.

When a breakpoint is reached, the debugger reports the current file, function, and next line to execute. You can inspect keys, compare them with the dictionary keys, and test a temporary correction without restarting the program.

The exclamation mark before d tells the debugger to evaluate a Python expression. This is necessary because d is also the pdb command for moving down the call stack.

Changing data in a debugger is useful for testing a theory, but the permanent fix still belongs in the source code. In this example, the program should validate missing keys or define the required fallback behavior rather than depend on a value inserted during a debugging session.

Run a Python Script in Debug Mode from the Terminal

You can start a Python script directly under pdb without adding breakpoint() to the source file.

</>
Copy
python -m pdb app.py

The debugger stops before the first executable statement. Set a breakpoint by filename and line number, and then continue:

</>
Copy
b app.py:25
c

You can also ask pdb to restart the program from the debugger prompt by using run or restart, depending on the debugging session and Python version.

Debug Python Code After an Exception

Post-mortem debugging opens the debugger at the point where an exception was raised. This is useful when the program fails before an expected breakpoint.

</>
Copy
import pdb

try:
    result = 10 / 0
except Exception:
    pdb.post_mortem()

Inside an interactive Python session, pdb.pm() can inspect the traceback from the most recently handled exception.

Debugging Python Code in Visual Studio Code

Visual Studio Code provides a graphical debugging interface through its Python support. It can set breakpoints, inspect local and global variables, evaluate expressions, view the call stack, and step through code.

  1. Open the Python project folder in Visual Studio Code.
  2. Make sure the Python extension is installed and the correct interpreter is selected.
  3. Open the Python file you want to debug.
  4. Click in the gutter beside a line number to set a breakpoint.
  5. Open Run and Debug.
  6. Select an appropriate Python debug configuration.
  7. Start debugging and use the toolbar to continue, step over, step into, step out, restart, or stop.

For simple files, Visual Studio Code can debug the active Python file without a custom configuration. Projects that require command-line arguments, environment variables, modules, or a specific working directory can use a launch.json configuration.

</>
Copy
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug current Python file",
      "type": "debugpy",
      "request": "launch",
      "program": "${file}",
      "console": "integratedTerminal"
    }
  ]
}

Conditional breakpoints are useful when a loop fails only for a particular value. Logpoints can print a message in the debug console without modifying the Python source code.

Debugging Python Data from JSON Payloads

JSON-related errors often come from missing fields, unexpected null values, incorrect types, or a different nesting structure. Inspect both the payload and the assumptions made by the code.

</>
Copy
import json

payload = '{"user": {"name": "Asha", "age": null}}'
data = json.loads(payload)

print(json.dumps(data, indent=2))
print("age value:", repr(data["user"].get("age")))
print("age type:", type(data["user"].get("age")).__name__)

Use dictionary access with brackets when a field is mandatory and a missing field should raise an error. Use get() when a missing field is expected and the program has a defined fallback. Do not use get() merely to suppress a data-contract problem.

Debugging Python Code with Unit Tests

A failing test provides a repeatable example of a bug. After identifying the failure, keep the test so the same defect does not return later.

</>
Copy
def normalize_username(username):
    return username.strip().lower()


def test_normalize_username_removes_spaces():
    assert normalize_username("  Alice  ") == "alice"

For a difficult failure, reduce the test input until it contains only the values necessary to reproduce the issue. This creates a focused case that is easier to inspect in a debugger.

Common Python Debugging Mistakes

  • Reading only the last traceback line: the exception type matters, but the preceding frames show how execution reached the failure.
  • Changing several things at once: multiple edits make it difficult to know which change affected the result.
  • Catching every exception: broad handlers can hide programming errors and remove useful traceback information.
  • Printing secrets: debug output should not expose passwords, access tokens, cookies, private keys, or personal data.
  • Leaving breakpoints in deployed code: an interactive breakpoint can suspend a production process.
  • Assuming the input type: values from forms, command-line arguments, environment variables, and JSON payloads may not have the expected type.
  • Fixing the symptom only: inserting a fallback value may hide a broken data contract or incorrect earlier calculation.

Python Debugging Tools in the Standard Library

Python moduleDebugging use
pdbInteractive source-level debugging.
tracebackFormatting, printing, and examining exception tracebacks.
loggingStructured diagnostic messages with levels and handlers.
faulthandlerDumping Python tracebacks during crashes, faults, or timeouts.
timeitMeasuring small code snippets when investigating performance.
cProfileFinding functions that consume significant execution time.
tracemallocTracing Python memory allocations.
unittestCreating repeatable tests for failures and regression fixes.

Choose a tool that matches the failure. Use pdb for control-flow and state inspection, logging for runtime diagnostics, cProfile for CPU performance, and tracemalloc for Python memory allocation analysis.

Python Code Debugging FAQs

How do I debug Python code step by step?

Add breakpoint() where the program should pause, run the script, and use n to execute the next line or s to enter a called function. Inspect expressions with p or pp, and use c to continue.

How do I run a Python script in debug mode from the terminal?

Run python -m pdb script.py. The built-in debugger starts before the first executable statement and accepts commands for breakpoints, stepping, expression inspection, and call-stack navigation.

How do I debug Python code in Visual Studio Code?

Select the correct Python interpreter, set a breakpoint in the editor gutter, open Run and Debug, and start a Python debugging configuration. Use the Variables, Watch, Call Stack, and Debug Console panels to inspect the program.

What is the difference between print debugging and pdb?

Print debugging records values chosen before the program runs. pdb pauses the program and lets you inspect arbitrary expressions, navigate stack frames, step through lines, and temporarily modify state during execution.

How do I debug a Python program that exits with no traceback?

Check whether the exception is being caught and suppressed, review logs and process exit codes, add logging around entry and exit points, and verify that the expected function is called. For crashes or hangs, the faulthandler module can provide additional traceback information.

Python Debugging Editorial QA Checklist

  • The traceback examples identify the exception type, failing line, and relevant program state.
  • All newly added terminal commands use the language-bash class unless the block contains output only.
  • All newly added output blocks use the output class.
  • Python examples can run independently or clearly state the required surrounding context.
  • The tutorial distinguishes temporary print debugging from maintainable application logging.
  • The pdb command table correctly separates step, next, continue, stack, and frame-navigation operations.
  • The Visual Studio Code example uses a Python debug configuration rather than an unrelated launch type.
  • JSON debugging guidance addresses missing keys, null values, nesting, and unexpected types.
  • Assertions are not presented as a replacement for runtime input validation.
  • Examples do not expose real credentials, tokens, personal data, or production identifiers.