Print debugging works until it doesn't. Here's how to level up.

The Built-in Debugger: pdb

Python ships with a debugger. Use it.

import pdb
 
def process_data(items):
    for item in items:
        pdb.set_trace()  # Execution stops here
        result = transform(item)

Once stopped, you can:

  • n - next line
  • s - step into function
  • c - continue to next breakpoint
  • p variable - print variable value
  • l - show current code context
  • q - quit debugger

breakpoint() is Better

Python 3.7+ has breakpoint(). Same thing, cleaner:

def process_data(items):
    for item in items:
        breakpoint()  # Same as pdb.set_trace()
        result = transform(item)

The magic: you can disable all breakpoints with an environment variable:

PYTHONBREAKPOINT=0 python script.py

Or use a different debugger:

PYTHONBREAKPOINT=ipdb.set_trace python script.py

Sometimes print is the right tool. Do it well:

# Bad
print(x)
print(data)
 
# Better
print(f"x = {x}")
print(f"data type: {type(data)}, len: {len(data)}")
 
# Best (Python 3.8+)
print(f"{x=}")
print(f"{data=}, {len(data)=}")

The f"{x=}" syntax prints both the variable name and value.

Strategic Print Placement

Don't just print everywhere. Be systematic:

def complex_function(input_data):
    print(f"[ENTER] complex_function: {input_data=}")
    
    # ... processing ...
    
    print(f"[EXIT] complex_function: {result=}")
    return result

Add entry/exit prints to suspect functions. Remove once fixed.

Use Logging Instead

For production code, logging beats print:

import logging
 
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
 
def process_data(items):
    logger.debug(f"Processing {len(items)} items")
    for i, item in enumerate(items):
        logger.debug(f"Item {i}: {item}")
        result = transform(item)
    logger.info("Processing complete")

Benefits:

  • Toggle verbosity without code changes
  • Timestamps included
  • Can write to files
  • Different levels (DEBUG, INFO, WARNING, ERROR)

Conditional Breakpoints

Only break when something interesting happens:

for i, item in enumerate(items):
    if item.status == "ERROR":
        breakpoint()
    process(item)

Or in pdb, set a conditional breakpoint:

(Pdb) b 42, item.status == "ERROR"

Breaks at line 42 only when condition is true.

Post-Mortem Debugging

Investigate crashes after they happen:

import pdb
 
try:
    risky_operation()
except Exception:
    pdb.post_mortem()

Or run your script with:

python -m pdb script.py

When it crashes, you drop into the debugger at the crash point.

Inspect the Stack

When you're lost, look at the call stack:

import traceback
 
def deep_function():
    traceback.print_stack()
    # Shows how you got here

In pdb:

  • w - show stack trace
  • u - go up one frame
  • d - go down one frame

Common Patterns

Finding Where a Value Changes

class WatchedValue:
    def __init__(self, value):
        self._value = value
    
    @property
    def value(self):
        return self._value
    
    @value.setter
    def value(self, new_value):
        print(f"Value changing: {self._value} -> {new_value}")
        traceback.print_stack()
        self._value = new_value

Debugging Loops

for i, item in enumerate(items):
    if i == 100:  # Break on specific iteration
        breakpoint()
    process(item)

Debugging Tests

pytest --pdb  # Drop into debugger on failure
pytest --pdb-first  # Stop at first failure

IDE Debugging

Your IDE's debugger is powerful. Learn it:

  1. Set breakpoints by clicking line numbers
  2. Inspect variables in the sidebar
  3. Step through code visually
  4. Set conditional breakpoints
  5. Watch expressions

Worth the 30 minutes to learn your IDE's debugger.

My Debugging Process

  1. Reproduce - Can you trigger the bug reliably?
  2. Isolate - What's the minimal code that shows it?
  3. Hypothesize - What do you think is wrong?
  4. Verify - Add a breakpoint or print to check
  5. Fix - Make the change
  6. Test - Confirm it's fixed, no regressions

Most bugs are found in step 2. Writing a minimal reproduction often reveals the issue.

When to Use What

SituationTool
Quick checkprint(f"{x=}")
Need to explore statebreakpoint()
Production issuelogging
Post-crash analysispdb.post_mortem()
Complex flowIDE debugger

Start simple. Escalate if needed.

React to this post: