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-20ll— 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.pyThis 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 pointThis 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 pdbppNow breakpoint() gives you:
- Syntax highlighting — code is colored and readable
- Tab completion — type
us+ Tab to completeuser_data - Sticky mode — shows code context that follows you
- Smart command parsing —
pisn't needed as much
breakpoint() # Now opens pdb++ instead of pdbTry 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 ipdbUse it directly:
import ipdb; ipdb.set_trace()Or configure it as the default breakpoint handler:
export PYTHONBREAKPOINT=ipdb.set_traceNow 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 debuggingThen 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.pyA 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 percentageRun 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.