I used to debug Python by adding print() statements everywhere. Dozens of them. Then I'd forget to remove half of them before committing. It was embarrassing and inefficient.

Learning pdb changed everything. Python's built-in debugger feels intimidating at first—all those single-letter commands—but once you get comfortable, you'll wonder how you ever lived without it. This is what I wish someone had told me when I started.

The breakpoint() Function: Start Here

Python 3.7 gave us breakpoint(). It's the cleanest way to pause execution:

def calculate_discount(price, discount_percent):
    discount = price * (discount_percent / 100)
    breakpoint()  # Execution pauses here
    final_price = price - discount
    return final_price
 
result = calculate_discount(100, 15)

When you run this, your terminal transforms into an interactive debugger:

> /path/to/script.py(4)calculate_discount()
-> final_price = price - discount
(Pdb) 

That (Pdb) prompt is your command line into the running program. You can inspect variables, step through code, and figure out what's actually happening.

Before Python 3.7, you had to write:

import pdb; pdb.set_trace()

Still works, but breakpoint() is cleaner and more flexible (we'll get to why later).

The Essential Commands

When I first saw pdb's command list, I panicked. There are dozens. But you really only need seven to start.

n (next) — Execute One Line

The n command runs the current line and moves to the next one:

(Pdb) n
> /path/to/script.py(5)calculate_discount()
-> return final_price

Use this to step through your code line by line. It doesn't go inside function calls—it steps over them.

s (step) — Step Into Functions

When you want to dive into a function call, use s:

def inner_function(x):
    return x * 2
 
def outer_function(value):
    breakpoint()
    result = inner_function(value)  # We're here
    return result
(Pdb) s
--Call--
> /path/to/script.py(1)inner_function()
-> def inner_function(x):

Now you're inside inner_function(). This is how you trace problems deep into your call stack.

c (continue) — Run Until Next Breakpoint

The c command lets your program run freely until it hits another breakpoint (or finishes):

def process_items(items):
    for item in items:
        breakpoint()  # Stops here each iteration
        print(f"Processing {item}")
(Pdb) c
> /path/to/script.py(3)process_items()
-> breakpoint()
(Pdb) c
> /path/to/script.py(3)process_items()
-> breakpoint()

l (list) — See Your Code

Lost in the code? The l command shows context:

(Pdb) l
  1     def calculate_discount(price, discount_percent):
  2         discount = price * (discount_percent / 100)
  3         breakpoint()
  4  ->     final_price = price - discount
  5         return final_price

The arrow -> shows where you are. You can also:

  • l 1, 20 — show lines 1-20
  • ll — show the entire current function

p (print) — Inspect Variables

This is the command you'll use constantly. It evaluates and prints any Python expression:

(Pdb) p price
100
(Pdb) p discount_percent
15
(Pdb) p discount
15.0
(Pdb) p price - discount
85.0
(Pdb) p type(discount)
<class 'float'>

You can run any valid Python:

(Pdb) p [x * 2 for x in range(5)]
[0, 2, 4, 6, 8]
(Pdb) p len(some_list)
42

pp (pretty print) — For Complex Data

When p gives you a wall of text, pp formats it nicely:

(Pdb) p user_data
{'id': 12345, 'name': 'Alice Johnson', 'email': 'alice@example.com', 'preferences': {'theme': 'dark', 'notifications': True, 'language': 'en'}}

(Pdb) pp user_data
{'email': 'alice@example.com',
 'id': 12345,
 'name': 'Alice Johnson',
 'preferences': {'language': 'en', 'notifications': True, 'theme': 'dark'}}

Essential for debugging API responses, database results, or any nested data.

w (where) — Show the Call Stack

When you're deep in nested function calls, w shows you how you got there:

(Pdb) w
  /path/to/script.py(25)<module>()
-> main()
  /path/to/script.py(20)main()
-> result = process_order(order)
  /path/to/script.py(12)process_order()
-> validated = validate_order(order)
> /path/to/script.py(5)validate_order()
-> breakpoint()

Read from bottom to top: <module> called main(), which called process_order(), which called validate_order(). The > marks your current position.

Post-Mortem Debugging

Here's a technique that blew my mind: you can start pdb after an exception occurs.

Method 1: pdb.pm() in the REPL

When an exception crashes your code in an interactive Python session:

>>> import my_module
>>> my_module.buggy_function()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "my_module.py", line 5, in buggy_function
    return data["missing_key"]
KeyError: 'missing_key'
 
>>> import pdb
>>> pdb.pm()
> /path/to/my_module.py(5)buggy_function()
-> return data["missing_key"]
(Pdb) p data
{'name': 'Alice', 'age': 30}

The pm() function (post-mortem) drops you into the debugger at the exact point where the exception occurred. You can inspect all the variables that existed at crash time.

Method 2: python -m pdb

Run your script with the pdb module:

python -m pdb my_script.py

This starts the debugger at the first line. More useful: if your script crashes, pdb catches the exception and lets you debug:

Traceback (most recent call last):
  File "my_script.py", line 15, in <module>
    process_data(None)
TypeError: 'NoneType' object is not subscriptable

Uncaught exception. Entering post-mortem debugging
> /path/to/my_script.py(8)process_data()
-> return data["key"]
(Pdb) 

Method 3: pdb.post_mortem()

Call this in an except block:

import pdb
import traceback
 
try:
    risky_operation()
except Exception as e:
    traceback.print_exc()
    pdb.post_mortem()  # Debug at the crash point

This is invaluable when you can't reproduce a bug consistently. Catch it and debug it in place.

Conditional Breakpoints

Sometimes you only want to stop when specific conditions are met. Maybe you're debugging a loop that runs 10,000 times, but the bug only appears on iteration 9,847.

Using the b Command

Set a breakpoint with a condition:

(Pdb) b 15, item_count > 100
Breakpoint 1 at /path/to/script.py:15

Now the debugger only stops at line 15 when item_count > 100.

You can use any Python expression:

(Pdb) b process_order, order.total > 1000
(Pdb) b 42, user.name == "Alice"
(Pdb) b validate, len(data) == 0

Conditional breakpoint() in Code

If you want to commit the breakpoint (temporarily), use a condition in your code:

def process_batch(items):
    for i, item in enumerate(items):
        if i == 9847:  # Only break on the problematic iteration
            breakpoint()
        result = process_item(item)

Or more elegantly:

def process_batch(items):
    for i, item in enumerate(items):
        if item.get("status") == "error":
            breakpoint()
        result = process_item(item)

Managing Breakpoints

(Pdb) b           # List all breakpoints
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at /path/to/script.py:15
	stop only if item_count > 100
2   breakpoint   keep yes   at /path/to/script.py:42

(Pdb) disable 1   # Disable breakpoint 1
(Pdb) enable 1    # Re-enable it
(Pdb) cl 1        # Clear (delete) breakpoint 1
(Pdb) cl          # Clear all breakpoints

Better Alternatives: pdb++ and ipdb

Stock pdb works, but it's... basic. Two alternatives make debugging much more pleasant.

pdb++

Install it and it automatically replaces pdb:

pip install pdbpp

Now breakpoint() gives you:

  • Syntax highlighting — code is colored and readable
  • Tab completion — type us + Tab to complete user_data
  • Sticky mode — shows code context that follows you
  • Smart command parsingp isn't needed as much
breakpoint()  # Now opens pdb++ instead of pdb

Try sticky mode:

(Pdb++) sticky

Now the code listing automatically updates as you step through. Game changer.

ipdb

If you use IPython, ipdb feels familiar:

pip install ipdb

Use it directly:

import ipdb; ipdb.set_trace()

Or configure it as the default breakpoint handler:

export PYTHONBREAKPOINT=ipdb.set_trace

Now every breakpoint() call uses ipdb. You get:

  • IPython's powerful introspection (? and ?? on objects)
  • Better tracebacks
  • History and multi-line editing

My Setup

I use pdb++ for most debugging and keep this in my shell config:

# ~/.zshrc or ~/.bashrc
export PYTHONBREAKPOINT=pdb.set_trace  # Default pdb++
alias pdb0='PYTHONBREAKPOINT=0'         # Disable breakpoints
alias pdbweb='PYTHONBREAKPOINT=web_pdb.set_trace'  # Remote debugging

Then I can:

# Normal debugging with pdb++
python my_script.py
 
# Run without stopping at breakpoints
pdb0 python my_script.py
 
# Debug remotely via web browser
pdbweb python my_script.py

A Practical Debugging Workflow

Let me walk through how I actually debug a problem.

The Scenario

I have a function that's returning wrong results:

def calculate_order_total(items, discount_code=None):
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    
    if discount_code:
        discount = get_discount(discount_code)
        subtotal = subtotal - (subtotal * discount)
    
    tax = subtotal * 0.08
    total = subtotal + tax
    
    return round(total, 2)

Orders with discount codes are showing weird totals.

Step 1: Add a Breakpoint

def calculate_order_total(items, discount_code=None):
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    
    if discount_code:
        breakpoint()  # Let's see what's happening here
        discount = get_discount(discount_code)
        subtotal = subtotal - (subtotal * discount)

Step 2: Run the Code

> /path/to/orders.py(6)calculate_order_total()
-> discount = get_discount(discount_code)
(Pdb) 

Step 3: Inspect the State

(Pdb) p discount_code
'SAVE20'
(Pdb) p subtotal
150.0
(Pdb) n  # Execute the discount lookup
(Pdb) p discount
20

Wait. The discount is 20, not 0.20. That's a 2000% discount!

Step 4: Trace the Problem

(Pdb) n
(Pdb) p subtotal
-2850.0

There it is. The subtotal went negative because we subtracted 150 * 20 instead of 150 * 0.20.

Step 5: Check the Source

Either get_discount() is returning a percentage (20) instead of a decimal (0.20), or our calculation expects the wrong format.

(Pdb) s  # Step into get_discount (on the next run)

Or:

(Pdb) p get_discount.__doc__
'Returns discount percentage as integer (e.g., 20 for 20% off)'

Bug found: get_discount() returns 20 for a 20% discount, but we're using it as if it's 0.20.

Step 6: Fix and Verify

subtotal = subtotal - (subtotal * (discount / 100))  # Convert percentage

Run with the breakpoint again to confirm:

(Pdb) p discount / 100
0.2
(Pdb) c
# Result: 129.6 ✓

Quick Reference

# Navigation
n               Next line (step over)
s               Step into function
c               Continue until next breakpoint
r               Continue until function returns
unt [line]      Continue until line number

# Inspection  
p expr          Print expression
pp expr         Pretty-print expression
l [start,end]   List source code
ll              List entire function
w               Show call stack (where)
a               Print arguments of current function

# Breakpoints
b [line]        Set breakpoint at line
b func          Break when function is called
b line, cond    Conditional breakpoint
cl [num]        Clear breakpoint(s)
disable num     Disable breakpoint
enable num      Enable breakpoint

# Control
q               Quit debugger
h [command]     Help
!statement      Execute Python statement

What I Learned

Debugging with pdb felt awkward at first. I kept reaching for print statements out of habit. But forcing myself to use the debugger for a week changed everything.

The biggest shift: instead of guessing where bugs might be and adding prints, I now pause execution and explore. I can inspect anything, try different values, and understand exactly what's happening.

Start simple. Drop a breakpoint(), use p to inspect variables, use n to step through. That's enough to solve most bugs. Add the other commands as you need them.

And install pdb++. The syntax highlighting alone is worth it.

React to this post: