Every Python developer debugs differently. Some reach for print statements. Others swear by IDE debuggers. The best developers know when to use which tool. Here's my guide to the debugging toolkit every Python developer should know.
pdb: The Built-in Debugger
Python's debugger is always available. No installs required.
import pdb
def calculate_total(items):
total = 0
for item in items:
pdb.set_trace() # Execution pauses here
total += item["price"] * item["quantity"]
return totalWhen execution hits set_trace(), you drop into an interactive prompt. Here are the commands you'll use daily:
Navigation:
n(next) — Execute current line, move to nexts(step) — Step into function callc(continue) — Run until next breakpointr(return) — Run until current function returns
Inspection:
p expression— Print expression valuepp expression— Pretty-print expressionl(list) — Show code around current linell(longlist) — Show entire current functionw(where) — Show stack trace
Breakpoints:
b 42— Set breakpoint at line 42b function_name— Break when function is calledb 42, x > 10— Conditional breakpointcl(clear) — Remove breakpoints
Example debugging session:
> calculate_total()
-> total += item["price"] * item["quantity"]
(Pdb) p item
{'name': 'Widget', 'price': 25.00, 'quantity': 3}
(Pdb) p total
0
(Pdb) n
(Pdb) p total
75.0
(Pdb) c
breakpoint(): The Modern Way
Python 3.7 introduced breakpoint() as a cleaner alternative:
def process_user(user_data):
validated = validate(user_data)
breakpoint() # Cleaner than import + set_trace()
return save_user(validated)It's not just cleaner—it's configurable. By default, breakpoint() calls pdb.set_trace(), but you can change that behavior.
PYTHONBREAKPOINT: The Secret Weapon
The PYTHONBREAKPOINT environment variable controls what breakpoint() does. This is incredibly powerful for switching debugging workflows without changing code.
Disable all breakpoints:
PYTHONBREAKPOINT=0 python app.pyYour code runs without stopping at any breakpoint() calls. Perfect for CI/CD or production.
Use ipdb (enhanced debugger):
pip install ipdb
PYTHONBREAKPOINT=ipdb.set_trace python app.pyipdb adds syntax highlighting, tab completion, and better introspection.
Use pudb (visual debugger):
pip install pudb
PYTHONBREAKPOINT=pudb.set_trace python app.pypudb gives you a full TUI (terminal UI) debugger with variable watches and stack visualization.
Use web-pdb (remote debugging):
pip install web-pdb
PYTHONBREAKPOINT=web_pdb.set_trace python app.pyOpens a web-based debugger—useful for debugging on remote servers.
Make it permanent in your shell:
# In ~/.bashrc or ~/.zshrc
export PYTHONBREAKPOINT=ipdb.set_traceNow every breakpoint() uses your preferred debugger.
Icecream: Print Debugging Evolved
The icecream library makes print debugging actually good:
pip install icecreamfrom icecream import ic
x = 42
ic(x) # ic| x: 42
data = {"name": "Alice", "score": 95}
ic(data) # ic| data: {'name': 'Alice', 'score': 95}Icecream automatically prints the expression and its value. No more writing print(f"x = {x}").
Why icecream beats print:
from icecream import ic
# Shows function name and line number
def process_order(order_id):
ic() # ic| process_order() at app.py:15
order = fetch_order(order_id)
ic(order.status) # ic| order.status: 'pending'
if order.is_valid():
ic() # ic| process_order() at app.py:21 (inside if block)
return process(order)Disable globally without removing calls:
from icecream import ic
ic.disable() # All ic() calls become no-ops
# ... later ...
ic.enable() # Turn it back onCustom output format:
from icecream import ic
import time
def time_format():
return f'{time.strftime("%H:%M:%S")} |> '
ic.configureOutput(prefix=time_format)
ic(x) # 14:32:05 |> x: 42Include context automatically:
ic.configureOutput(includeContext=True)
ic(result) # ic| app.py:42 in calculate() - result: 150Print Debugging Done Right
Sometimes print is the right tool. Here's how to do it well.
Python 3.8+ f-string debugging:
name = "Alice"
count = 42
items = [1, 2, 3]
# The = in f-strings prints variable name and value
print(f"{name=}") # name='Alice'
print(f"{count=}") # count=42
print(f"{len(items)=}") # len(items)=3This syntax works with any expression:
print(f"{user.name=}") # user.name='Bob'
print(f"{2 + 2=}") # 2 + 2=4
print(f"{data.get('key')=}") # data.get('key')='value'Structured debug output:
def debug_function(func):
def wrapper(*args, **kwargs):
print(f"[ENTER] {func.__name__}({args=}, {kwargs=})")
result = func(*args, **kwargs)
print(f"[EXIT] {func.__name__} -> {result=}")
return result
return wrapper
@debug_function
def calculate(x, y, operation="add"):
if operation == "add":
return x + y
return x - y
calculate(5, 3, operation="add")
# [ENTER] calculate(args=(5, 3), kwargs={'operation': 'add'})
# [EXIT] calculate -> result=8Quick cleanup with grep:
# Prefix debug prints so you can find and remove them
print(f"DEBUG: {variable=}") # Easy to grep -r "DEBUG:" and clean upChoosing the Right Tool
| Situation | Tool |
|---|---|
| Quick value check | print(f"{x=}") or ic(x) |
| Explore program state | breakpoint() |
| Step through logic | pdb commands (n, s, c) |
| Production debugging | logging module |
| Complex data structures | icecream with ic() |
| Remote server | web-pdb via PYTHONBREAKPOINT |
| CI/CD runs | PYTHONBREAKPOINT=0 |
My Workflow
- First instinct:
ic()for quick checks—it's fast and shows context - Need to explore:
breakpoint()to pause and poke around - Complex flow: Set multiple breakpoints, use
cto jump between them - Production issue: Add logging, check logs, reproduce locally
- Before commit:
grep -r "ic(" . && grep -r "breakpoint(" .to catch stragglers
The best debugger is the one you actually use. Learn these tools, and you'll spend less time wondering what your code is doing and more time making it do the right thing.