Debugging with pdb

Intermediate45 min

Go beyond breakpoint() with conditional breakpoints, post-mortem debugging of crashes, scripted sessions, and attaching pdb to a running process.

Builds on
What you will learn
  • Set, list and remove breakpoints, including conditional ones
  • Move through the call stack with where, up and down, and watch values with display
  • Debug a crash after the fact with post-mortem mode
  • Control breakpoint() with PYTHONBREAKPOINT, and attach pdb to a running process
On this page

The debugging habits lesson taught a routine and the first pdb commands: breakpoint(), then p, n, s, c, l, w and q. That is enough when you know which line to stop on. This lesson covers what you need when you do not: stopping only in the interesting iteration, inspecting a crash after it happened, and looking inside a program that is already running.

To keep the sessions below reproducible, many are driven by a script that feeds pdb its commands as text. At your keyboard you type the same commands one at a time after the (Pdb) prompt.

Breakpoints you set from inside pdb

You do not have to edit the file to stop somewhere. At the prompt, break (or b) sets a breakpoint at a line number or function name, and continue runs until the next one. Here is the tax function from the beginner lesson, driven by a script:

c39_scripted_pdb.py
"""Drive pdb from a list of commands, so the session can be checked."""
import io
import os
import pdb
def total_with_tax(prices, rate):
total = 0
for price in prices:
total += price
return total * 1 + rate

The driver feeds pdb its commands and prints the captured session:

c39_scripted_pdb.py (continued)
commands = """\
break 12
continue
p total
p rate
p total * 1 + rate
p total * (1 + rate)
continue
"""
out = io.StringIO()
debugger = pdb.Pdb(stdin=io.StringIO(commands), stdout=out)
result = debugger.runcall(total_with_tax, [10, 20], 0.1)
text = out.getvalue().replace(os.getcwd() + os.sep, "")
print(text)
print("returned", result)
Output
> c39_scripted_pdb.py(9)total_with_tax()
-> total = 0
(Pdb) Breakpoint 1 at c39_scripted_pdb.py:12
(Pdb) > c39_scripted_pdb.py(12)total_with_tax()
-> return total * 1 + rate
(Pdb) 30
(Pdb) 0.1
(Pdb) 30.1
(Pdb) 33.0
(Pdb)
returned 30.1

runcall starts the function under the debugger, which stops on its first line. break 12 sets a breakpoint on the return, and continue runs the loop at full speed and stops there. Then four p commands inspect the values, including the two competing expressions: total * 1 + rate gives the wrong 30.1 and total * (1 + rate) gives 33.0. The last continue lets the function finish. Your typed commands would appear after each (Pdb) prompt, which a script cannot echo.

Useful companions of break:

Command Effect
break List all breakpoints, with hit counts
break 20 / break module.py:20 / break function Set a breakpoint at a line or function
break 20, i == 3 Set a conditional breakpoint: stop only when the expression is true
tbreak 20 A temporary breakpoint, removed after the first hit
disable 1 / enable 1 / clear 1 Turn breakpoint 1 off, back on, or delete it
until / unt 25 Run until the line number is greater than the current one, or until line 25: leaves a loop
return Run until the current function is about to return

Stop only when it matters: conditional breakpoints

A loop with ten thousand iterations is hopeless with n and c alone. Give the breakpoint a condition and pdb stops only when it holds. display prints an expression every time execution stops in that frame, and notes when its value has changed.

c39_ex_cond.py
import io
import os
import pdb
def running_total(numbers):
total = 0
for i, n in enumerate(numbers):
total += n
return total
c39_ex_cond.py (continued)
commands = """\
break 9, i == 3
continue
p i, n, total
display total
next
next
continue
"""
out = io.StringIO()
debugger = pdb.Pdb(stdin=io.StringIO(commands), stdout=out)
result = debugger.runcall(running_total, [5, 10, 15, 20, 25])
print(out.getvalue().replace(os.getcwd() + os.sep, ""))
print("returned", result)
Output
> c39_ex_cond.py(7)running_total()
-> total = 0
(Pdb) Breakpoint 1 at c39_ex_cond.py:9
(Pdb) > c39_ex_cond.py(9)running_total()
-> total += n
(Pdb) (3, 20, 30)
(Pdb) display total: 30
(Pdb) > c39_ex_cond.py(8)running_total()
-> for i, n in enumerate(numbers):
display total: 50 [old: 30]
(Pdb) > c39_ex_cond.py(9)running_total()
-> total += n
(Pdb)
returned 75

The breakpoint on line 9 has the condition i == 3, so pdb skipped the first three iterations. At the stop, p i, n, total printed (3, 20, 30). After display total, the next next moved to the for line and pdb reported that total changed from 30 to 50, because the line had run. The final continue finished the function with 75.

Post-mortem: inspect a crash after it happened

When a program has already crashed, you do not need to guess a breakpoint and reproduce it. Post-mortem debugging opens the debugger at the point where the exception was raised, with every frame’s variables still alive. Run the script under pdb:

Terminal window
$ python -m pdb crash.py

If the script ends with an uncaught exception, pdb prints the traceback and drops into a (Pdb) prompt inside the failing frame. In an interactive session, import pdb; pdb.pm() does the same for the last exception, and pdb.post_mortem() does it from an except block. This script post-mortems a caught KeyError:

c39_postmortem.py
"""Inspect a crash after the fact, with a scripted post-mortem session."""
import io
import os
import pdb
import sys
def average_by_group(rows):
sums = {}
for group, value in rows:
sums[group] = sums[group] + value
return sums
rows = [("a", 1), ("b", 2)]
try:
average_by_group(rows)
except KeyError:
traceback = sys.exc_info()[2]
c39_postmortem.py (continued)
commands = """\
where
p group
p value
p sums
up
p rows
quit
"""
out = io.StringIO()
debugger = pdb.Pdb(stdin=io.StringIO(commands), stdout=out)
debugger.reset()
debugger.interaction(None, traceback)
print(out.getvalue().replace(os.getcwd() + os.sep, ""))
Output
> c39_postmortem.py(12)average_by_group()
-> sums[group] = sums[group] + value
(Pdb) c39_postmortem.py(18)<module>()
-> average_by_group(rows)
> c39_postmortem.py(12)average_by_group()
-> sums[group] = sums[group] + value
(Pdb) 'a'
(Pdb) 1
(Pdb) {}
(Pdb) > c39_postmortem.py(18)<module>()
-> average_by_group(rows)
(Pdb) [('a', 1), ('b', 2)]
(Pdb)

interaction is the lower-level call that post_mortem wraps. The session lands on line 12, and the bug is plain once you can look: group is 'a' and sums is empty, so sums[group] raises the KeyError. where (also w) lists the stack with an arrow at the current frame, up moves to the caller’s frame so you can look at its variables (rows), and down moves back.

Turning breakpoint() on and off

breakpoint() calls sys.breakpointhook(), which by default starts pdb. The environment variable PYTHONBREAKPOINT changes what it does, without touching the source. Setting it to 0 disables every breakpoint() call. Setting it to the dotted name of a callable runs that instead:

c39_breakpoint_off.py
"""breakpoint() calls sys.breakpointhook(), which PYTHONBREAKPOINT controls."""
import os
import subprocess
import sys
child_code = """
x = 41
breakpoint()
print("finished without stopping:", x + 1)
"""
def run_child(setting):
env = dict(os.environ, PYTHONBREAKPOINT=setting)
command = [sys.executable, "-B", "-c", child_code]
run = subprocess.run(
command,
env=env,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
return run.stdout
c39_breakpoint_off.py (continued)
print(run_child("0"), end="")
print(repr(run_child("builtins.print")))
Output
finished without stopping: 42
'\nfinished without stopping: 42\n'

With PYTHONBREAKPOINT=0 the program ran straight through. With builtins.print, the breakpoint() call became print(), which printed an empty line first. In real use, the value is often the entry function of a different debugger. breakpoint() has been available since 3.7. Run a test suite or a deployed service with PYTHONBREAKPOINT=0 and a forgotten breakpoint() cannot freeze it.

Attaching to a running process

Sometimes the problem is in a program that is already running: a server that has hung, a job that has been going for an hour. pdb can attach to a running Python process by its process ID Requires version 3.14 or later (PEP 768):

Terminal window
$ python -m pdb -p 1234

The -p (or --pid) option takes the PID of a process running Python 3.14 or newer, as described in the pdb documentation. Here is a long-running script to try it on. Start it in one terminal and note the PID that it prints:

import os
import time
def work(n):
total = 0
for i in range(n):
total += i
time.sleep(0.2)
return total
print("pid", os.getpid(), flush=True)
print(work(200))

In a second terminal, attach with the PID. The commands you type are the usual ones. This session was recorded against the script above with the PID it printed:

Terminal window
$ python -m pdb -p 18660
> /home/you/work.py(9)work()
-> time.sleep(0.2)
(Pdb) where
/home/you/work.py(14)<module>()
-> print(work(200))
> /home/you/work.py(9)work()
-> time.sleep(0.2)
(Pdb) p i
41
(Pdb) p total
861
(Pdb) quit

pdb stopped the program at whatever line it was executing, showed its stack, and let us read i and total from the live process. A process blocked in a system call, such as time.sleep, only responds once its next bytecode instruction runs, so expect a short wait. Attaching also needs operating-system permission to inspect the target, so it works on your own processes and may be refused for others’. When you only want a stack trace and do not need to step, faulthandler can dump every thread’s stack on a signal.

Common pitfalls

  • Leaving breakpoint() in code. A forgotten call freezes a program with nobody at the keyboard. Search for it before committing, or run with PYTHONBREAKPOINT=0.
  • A variable named like a pdb command. n, c, l and p are commands. Use p n or !n to see your variable.
  • Debugging in a test run with output capture. pytest captures output, which hides the pdb prompt. Use pytest --pdb to drop into post-mortem on a failing test, or pytest -s to turn capture off.
  • Attaching to the wrong process. Check the PID. A shell command like pgrep -f server.py helps, and the attach needs Python 3.14 or newer in the target, not only in your terminal.
ExerciseStop only on the interesting iteration

Write down the pdb commands that stop running_total([5, 10, 15, 20, 25]) on the iteration where i == 3, print i, n and total as a tuple, and then run the function to the end. The breakpoint is on the line total += n. What does the tuple print?

Show solution

The commands are the ones from the session above:

pdb commands
break 9, i == 3
continue
p i, n, total
continue

The tuple is (3, 20, 30): at iteration 3 the value of n is 20, and total is still 5 + 10 + 15 = 30 because the stop happens before the += runs. The function returns 75.

ExerciseFind four bugs with pdb

This inventory report runs without any error, and all four numbers it prints are wrong. The data is apple,3,0.5, pear,12,0.25, fig,5,2.0, plum,20,0.1. The correct output is: total value 16.5, most valuable item fig (by quantity times price), low stock (5 or fewer) ['apple', 'fig'], and names apple, fig, pear, plum. Use breakpoint() or python -m pdb to inspect each function, find the bug in each, and fix it.

c39_ex_stock.py
LINES = ["apple,3,0.5", "pear,12,0.25", "fig,5,2.0", "plum,20,0.1"]
def parse_line(line):
name, qty, price = line.split(",")
return name, int(qty), float(price)
def total_value(items):
total = 0
for name, qty, price in items:
total = qty * price
return total
def most_valuable(items):
best = None
for item in items:
if best is None or item[1] * item[2] < best[1] * best[2]:
best = item
return best[0]

The report functions and the printing follow:

c39_ex_stock.py (continued)
def low_stock(items, limit=5):
return [name for name, qty, price in items if qty < limit]
def name_list(items):
names = sorted(name for name, qty, price in items)
return ", ".join(names[:-1])
items = [parse_line(line) for line in LINES]
print("total value:", total_value(items))
print("most valuable:", most_valuable(items))
print("low stock:", low_stock(items))
print("names:", name_list(items))
Output
total value: 2.0
most valuable: apple
low stock: ['apple']
names: apple, fig, pear
Show solution

Stopping inside each function and printing the values shows:

  1. total_value: after the loop, p total prints 2.0, the value of the last item alone. The line total = qty * price replaces the total on every pass. It should be total += qty * price.
  2. most_valuable: the comparison picks the item with the smallest value. p item[1] * item[2] and p best[1] * best[2] show the order is reversed. Use >.
  3. low_stock: the requirement says 5 or fewer, and fig has exactly 5. Use qty <= limit.
  4. name_list: p names[:-1] shows the slice drops the last name. Join names.

With those four edits, the script prints the correct report:

Terminal window
$ python c39_ex_stock_fixed.py
total value: 16.5
most valuable: fig
low stock: ['apple', 'fig']
names: apple, fig, pear, plum

Boundary values like fig with exactly 5 are the ones a test should include, as in the test-driven workflow lesson.

ExerciseStop once, on the first big number

Using the same running_total([5, 10, 15, 20, 25]) function, write the commands that stop only the first time n is greater than 12, print i, n and total as a tuple, and then finish the run. Use a temporary breakpoint. What does the tuple print?

Show solution

tbreak takes a condition like break does, and it deletes itself after the first hit:

pdb commands
tbreak 9, n > 12
continue
p i, n, total
continue
Terminal window
$ python c39_ex_tbreak.py
> c39_ex_tbreak.py(7)running_total()
-> total = 0
(Pdb) Breakpoint 1 at c39_ex_tbreak.py:9
(Pdb) Deleted breakpoint 1 at c39_ex_tbreak.py:9
> c39_ex_tbreak.py(9)running_total()
-> total += n
(Pdb) (2, 15, 15)
(Pdb)
returned 75

The stop is at i == 2, where n is 15 and total is 15. Deleted breakpoint 1 shows the temporary breakpoint is gone, so the second continue runs to the end.

Summary

  • Set breakpoints from the prompt with break, make them conditional with break 9, i == 3, and use tbreak, until, return and display to avoid stepping line by line.
  • After a crash, python -m pdb script.py, pdb.pm() or pdb.post_mortem() opens the debugger inside the failing frame. Use where, up and down to look around.
  • PYTHONBREAKPOINT=0 disables every breakpoint() call without editing code.
  • python -m pdb -p PID Requires version 3.14 or later attaches to a running Python 3.14+ process so you can inspect it without restarting.
  • The next lesson turns from “is it right?” to “is it fast?”.