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 total

When 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 next
  • s (step) — Step into function call
  • c (continue) — Run until next breakpoint
  • r (return) — Run until current function returns

Inspection:

  • p expression — Print expression value
  • pp expression — Pretty-print expression
  • l (list) — Show code around current line
  • ll (longlist) — Show entire current function
  • w (where) — Show stack trace

Breakpoints:

  • b 42 — Set breakpoint at line 42
  • b function_name — Break when function is called
  • b 42, x > 10 — Conditional breakpoint
  • cl (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.py

Your 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.py

ipdb adds syntax highlighting, tab completion, and better introspection.

Use pudb (visual debugger):

pip install pudb
PYTHONBREAKPOINT=pudb.set_trace python app.py

pudb 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.py

Opens a web-based debugger—useful for debugging on remote servers.

Make it permanent in your shell:

# In ~/.bashrc or ~/.zshrc
export PYTHONBREAKPOINT=ipdb.set_trace

Now every breakpoint() uses your preferred debugger.

Icecream: Print Debugging Evolved

The icecream library makes print debugging actually good:

pip install icecream
from 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 on

Custom 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: 42

Include context automatically:

ic.configureOutput(includeContext=True)
 
ic(result)  # ic| app.py:42 in calculate() - result: 150

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)=3

This 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=8

Quick cleanup with grep:

# Prefix debug prints so you can find and remove them
print(f"DEBUG: {variable=}")  # Easy to grep -r "DEBUG:" and clean up

Choosing the Right Tool

SituationTool
Quick value checkprint(f"{x=}") or ic(x)
Explore program statebreakpoint()
Step through logicpdb commands (n, s, c)
Production debugginglogging module
Complex data structuresicecream with ic()
Remote serverweb-pdb via PYTHONBREAKPOINT
CI/CD runsPYTHONBREAKPOINT=0

My Workflow

  1. First instinct: ic() for quick checks—it's fast and shows context
  2. Need to explore: breakpoint() to pause and poke around
  3. Complex flow: Set multiple breakpoints, use c to jump between them
  4. Production issue: Add logging, check logs, reproduce locally
  5. 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.

React to this post: