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 lines- step into functionc- continue to next breakpointp variable- print variable valuel- show current code contextq- 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.pyOr use a different debugger:
PYTHONBREAKPOINT=ipdb.set_trace python script.pyPrint Debugging Done Right
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 resultAdd 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.pyWhen 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 hereIn pdb:
w- show stack traceu- go up one framed- 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_valueDebugging 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 failureIDE Debugging
Your IDE's debugger is powerful. Learn it:
- Set breakpoints by clicking line numbers
- Inspect variables in the sidebar
- Step through code visually
- Set conditional breakpoints
- Watch expressions
Worth the 30 minutes to learn your IDE's debugger.
My Debugging Process
- Reproduce - Can you trigger the bug reliably?
- Isolate - What's the minimal code that shows it?
- Hypothesize - What do you think is wrong?
- Verify - Add a breakpoint or print to check
- Fix - Make the change
- 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
| Situation | Tool |
|---|---|
| Quick check | print(f"{x=}") |
| Need to explore state | breakpoint() |
| Production issue | logging |
| Post-crash analysis | pdb.post_mortem() |
| Complex flow | IDE debugger |
Start simple. Escalate if needed.