cheat sheet
pdb
Pause, inspect, and step through Python programs with pdb. Covers breakpoint(), command reference, post-mortem debugging, conditional breakpoints, and enhanced alternatives like ipdb, pdbpp, and pudb.
pdb — Python Debugger
What it is
pdb is Python's built-in interactive debugger, shipped in the standard library since Python 1.5. It implements a console REPL that suspends program execution at a chosen point, lets you walk the call stack, inspect or mutate any in-scope variable, evaluate arbitrary expressions, step line-by-line, and resume. Because it is stdlib, it works in every Python environment with zero setup — no IDE, no extra dependencies. For richer ergonomics (tab completion, syntax highlighting, sticky frames) reach for ipdb, pdbpp, or pudb; for a graphical session, attach VS Code or PyCharm via the Debug Adapter Protocol.
Install
pdb is part of the standard library — nothing to install. The enhanced alternatives are pip-installable:
# Stdlib — already there
python -c "import pdb; print(pdb.__doc__[:60])"
# Optional: IPython-flavored prompt with tab completion + syntax colors
pip install ipdb
# Optional: drop-in replacement with sticky mode + better tracebacks
pip install pdbpp
# Optional: curses-based full-screen TUI debugger
pip install pudb
Output:
The Python Debugger Pdb
=======================
To use the debugger in its simplest form:
Syntax
The two canonical entry points are the interactive command python -m pdb script.py (start under the debugger from line 1) and the in-source call breakpoint() (drop into the debugger at that line when execution reaches it).
# Launch a script under pdb from the start
python -m pdb script.py [ARGS...]
# Run a module under pdb
python -m pdb -m mypackage.cli [ARGS...]
# Continue running after an uncaught exception (post-mortem mode)
python -m pdb -c continue script.py
Output: (none — exits 0 on success)
Essential pdb commands
These are the commands you type at the (Pdb) prompt. Most have one-letter aliases.
| Command | Alias | Meaning |
|---|---|---|
help [cmd] | h | List commands or get help for one |
next | n | Execute the current line; step over calls |
step | s | Step into a function call |
continue | c | Resume until the next breakpoint or exit |
return | r | Continue until the current function returns |
until [LINE] | unt | Continue until line number LINE (or past current loop) |
list [first[,last]] | l | Show 11 lines around the current line |
longlist | ll | Show the whole current function/frame |
print EXPR | p | Print the value of an expression |
pp EXPR | pp | Pretty-print (uses pprint.pprint) |
whatis EXPR | — | Show the type of an expression |
where | w / bt | Show the stack trace |
up [N] | u | Move N frames up in the stack |
down [N] | d | Move N frames down in the stack |
| `break [FILE:LINE | FUNC]` | b |
tbreak ... | — | Temporary breakpoint (auto-clear on hit) |
clear [BPNUM] | cl | Remove breakpoints |
condition BPNUM EXPR | — | Make a breakpoint conditional |
ignore BPNUM COUNT | — | Skip the next COUNT hits |
commands BPNUM | — | Attach a script to a breakpoint |
jump LINE | j | Jump to another line (no execution in between) |
display EXPR | — | Re-show EXPR at every stop in this frame |
interact | — | Drop into a full Python REPL with the current scope |
args | a | Print current frame's arguments |
quit | q | Abandon the program |
breakpoint() — the modern entry point
Since Python 3.7, the built-in breakpoint() is the preferred way to drop into a debugger. It dispatches to whatever the PYTHONBREAKPOINT environment variable points at (default: pdb.set_trace). This means you can swap debuggers project-wide by setting one env var — no code changes.
# app.py
def compute(rows: list[dict]) -> int:
total = 0
for row in rows:
breakpoint() # 3.7+ — pauses here
total += row["value"]
return total
compute([{"value": 10}, {"value": 20}])
python app.py
Output:
> /home/alice/app.py(5)compute()
-> total += row["value"]
(Pdb) p row
{'value': 10}
(Pdb) p total
0
(Pdb) c
> /home/alice/app.py(5)compute()
-> total += row["value"]
(Pdb) c
PYTHONBREAKPOINT — swap the debugger globally
PYTHONBREAKPOINT accepts any dotted path to a callable. Set it to ipdb.set_trace to use IPython-flavored pdb without editing source. Set it to 0 to disable every breakpoint() call in production — useful for shipping debug-instrumented code that stays inert when not requested.
# Use ipdb instead of stock pdb
PYTHONBREAKPOINT=ipdb.set_trace python app.py
# Disable all breakpoint() calls (turns them into no-ops)
PYTHONBREAKPOINT=0 python app.py
# Use VS Code's debugpy listener
PYTHONBREAKPOINT=debugpy.breakpoint python app.py
Output: (none — exits 0 on success)
Setting breakpoints from the prompt
Inside a (Pdb) session, break (or b) sets a breakpoint by line number, by function name, or with a condition. Breakpoints persist across continues until you clear them, so you can set several upfront and then c your way through the program.
(Pdb) b 42 # break at line 42 of the current file
(Pdb) b app.py:42 # break at line 42 of app.py
(Pdb) b compute # break at the start of compute()
(Pdb) b compute, total > 100 # break in compute() only when total > 100
(Pdb) b # list all breakpoints
Num Type Disp Enb Where
1 breakpoint keep yes at /home/alice/app.py:42
(Pdb) condition 1 total > 100 # make existing bp 1 conditional
(Pdb) ignore 1 5 # skip the next 5 hits of bp 1
(Pdb) cl 1 # clear bp 1
(Pdb) cl # clear ALL breakpoints (after confirm)
Inspecting and mutating state
At any stop, the prompt has access to the full local + global scope of the current frame. You can print, pretty-print, run arbitrary expressions, mutate locals, and even reassign function arguments — handy for hot-patching a value to see what would have happened if a bug hadn't fired.
(Pdb) p row # print
{'value': 10}
(Pdb) pp some_big_dict # pretty-print
{'a': 1,
'b': [1, 2, 3, 4, 5],
...}
(Pdb) whatis row # what type is it?
<class 'dict'>
(Pdb) row["value"] = 999 # mutate a local
(Pdb) p row
{'value': 999}
(Pdb) ! total = 0 # the ! prefix runs any Python statement
(Pdb) interact # drop into a full REPL with this scope
*pdb interact start*
>>> [r["value"] for r in rows]
[10, 20]
>>> exit()
*exit from pdb interact*
Navigating the call stack
where (alias w or bt) prints the stack trace with the current frame marked. up moves to the caller's frame so you can inspect the variables one level up; down moves back. This is the bread and butter of post-mortem debugging — when an exception bubbles up, you walk back down to where the bad value was created.
(Pdb) w
/home/alice/run.py(12)<module>()
-> compute(rows)
/home/alice/app.py(5)compute()
-> total += row["value"]
> /home/alice/app.py(5)compute()
-> total += row["value"]
(Pdb) u
> /home/alice/run.py(12)<module>()
-> compute(rows)
(Pdb) p rows
[{'value': 10}, {'value': 20}]
(Pdb) d
> /home/alice/app.py(5)compute()
-> total += row["value"]
Post-mortem debugging
When a Python program crashes outside a debugger, pdb can still drop you into the exact frame where the exception was raised, with all locals intact. There are three common entry points: run the script under python -m pdb -c continue, call pdb.post_mortem() programmatically from an except clause, or use pdb.pm() in the REPL after an interactive session crashes.
# crash.py
def divide(a, b):
return a / b
def main():
pairs = [(10, 2), (10, 0), (10, 5)]
return [divide(a, b) for a, b in pairs]
main()
# Option A: re-run the script under pdb and continue until it crashes
python -m pdb -c continue crash.py
Output:
Traceback (most recent call last):
File "/home/alice/crash.py", line 6, in main
return [divide(a, b) for a, b in pairs]
File "/home/alice/crash.py", line 2, in divide
return a / b
ZeroDivisionError: division by zero
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> /home/alice/crash.py(2)divide()
-> return a / b
(Pdb) a # show frame arguments
a = 10
b = 0
(Pdb) w
/home/alice/crash.py(8)<module>()
-> main()
/home/alice/crash.py(6)main()
-> return [divide(a, b) for a, b in pairs]
> /home/alice/crash.py(2)divide()
-> return a / b
Programmatic post-mortem
Wrap the entry point in a try/except that calls pdb.post_mortem() only when an env var is set. This pattern ships in production but stays inert unless you opt in.
# main.py
import os
import pdb
import sys
import traceback
def main():
raise ValueError("simulated failure")
if __name__ == "__main__":
try:
main()
except Exception:
if os.environ.get("DEBUG_PM"):
traceback.print_exc()
pdb.post_mortem()
else:
raise
DEBUG_PM=1 python main.py
Output:
Traceback (most recent call last):
File "/home/alice/main.py", line 11, in <module>
main()
File "/home/alice/main.py", line 7, in main
raise ValueError("simulated failure")
ValueError: simulated failure
> /home/alice/main.py(7)main()
-> raise ValueError("simulated failure")
(Pdb)
Conditional and one-shot breakpoints
A conditional breakpoint pauses only when an expression evaluates truthy, and a temporary breakpoint (tbreak) auto-clears after the first hit. These two combined let you isolate a single problematic iteration of a long loop without stopping the world.
# loop.py
def process(items):
for i, item in enumerate(items):
result = item ** 2
print(i, result)
process(range(1000))
python -m pdb loop.py
Output:
(Pdb) tbreak loop.py:4, i == 742
Breakpoint 1 at /home/alice/loop.py:4
(Pdb) c
0 0
1 1
...
> /home/alice/loop.py(4)process()
-> print(i, result)
(Pdb) p i, result
(742, 550564)
(Pdb) c
743 552049
...
display and commands — automatic output
display EXPR re-prints an expression after every prompt return in the current frame — the closest pdb gets to a watch window. commands BPNUM attaches an arbitrary script to a breakpoint so you can print state and continue automatically every time it fires.
(Pdb) display total
display total: 0
(Pdb) n
> /home/alice/app.py(5)compute()
-> total += row["value"]
display total: 0 [old: ...]
(Pdb) n
display total: 10
(Pdb) b 5
Breakpoint 1 at /home/alice/app.py:5
(Pdb) commands 1
(com) p total
(com) c
(com) end
(Pdb) c
0
10
30
Running under pytest
pytest integrates with pdb via two complementary flags. --pdb drops into the debugger on the first test failure or error. --trace drops into the debugger at the start of every test. With breakpoint() it Just Works — pytest captures stdout/stderr by default, so passing -s is often required to see the pdb prompt.
# Drop into pdb on first failing assertion
pytest --pdb
# Drop into pdb at the start of every test
pytest --trace
# Show stdout/stderr live — needed so the (Pdb) prompt is visible
pytest --pdb -s
# Use ipdb instead
PYTHONBREAKPOINT=ipdb.set_trace pytest -s
Output: (none — exits 0 on success)
Enhanced alternatives
These pip-installable debuggers extend pdb with completion, colors, and richer UIs. All three are drop-in: set PYTHONBREAKPOINT and your existing breakpoint() calls route to the new tool.
| Tool | What it adds |
|---|---|
ipdb | IPython-style prompt: tab completion, syntax-colored tracebacks, multi-line input |
pdbpp | Drop-in pdb replacement with sticky mode (full function visible while stepping), smarter list, and tab completion |
pudb | Curses-based full-screen TUI: variables pane, stack pane, breakpoints pane — keyboard-driven |
debugpy | VS Code's DAP server; attach the editor for graphical debugging |
# Pick one of these — they all replace stock pdb when breakpoint() fires
export PYTHONBREAKPOINT=ipdb.set_trace
export PYTHONBREAKPOINT=pudb.set_trace
# Quick check that the substitution took effect
python -c "breakpoint()"
Output:
> <string>(1)<module>()
ipdb>
IDE / DAP integration
VS Code, PyCharm, Neovim (dap-python), and Emacs (dape) all attach to a running Python program through the Debug Adapter Protocol. The Python side is the debugpy package — start it once, expose a port, and the editor takes over the role of the (Pdb) prompt with breakpoints set graphically in the gutter.
# Install debugpy
pip install debugpy
# Run a script and wait for an editor to attach on port 5678
python -m debugpy --listen 0.0.0.0:5678 --wait-for-client app.py
Output: (none — exits 0 on success)
// .vscode/launch.json — attach to the listening process above
{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Attach",
"type": "debugpy",
"request": "attach",
"connect": { "host": "127.0.0.1", "port": 5678 },
"pathMappings": [
{ "localRoot": "${workspaceFolder}", "remoteRoot": "." }
]
}
]
}
Output: (none — exits 0 on success)
Common pitfalls
- Forgetting
-sunder pytest — pytest captures stdout, so the(Pdb)prompt is invisible. Always pair--pdbwith-s. breakpoint()left in committed code — add aruffrule (T100— "trace found") or apre-commithook to fail on accidentalbreakpoint()calls.continueinside a generator / async function — pdb resumes only the current task; other awaitables stay paused. Step withnand watch the event loop, notc.- Mutating locals doesn't persist after
c— in CPython, writes tolocals()in a function frame are discarded. Mutate dicts, lists, and objects (which are references) instead of rebinding names. pvsppconfusion —pusesrepr(), which can be a single 4000-character line for a big dict.ppinvokespprint.pprintwith width 80. Reach forppwhenever the object is bigger than a one-liner.- C extensions are opaque — pdb steps through Python bytecode only. Inside
numpy,pandas, or any C-level call you'll see(Pdb)jump back out without ever entering. Usegdbwith the CPython debug symbols for those. jumponly works in the bottom frame and not across blocks — you can't jump out of anexceptclause, into a loop, or backwards over areturn. Useful but narrow.- Remote / SSH sessions need a TTY —
breakpoint()undernohupor in a Docker container without-itwill hang silently. Usedocker exec -itor setPYTHONBREAKPOINT=remote_pdb.set_trace(pip install remote-pdb) to listen on a socket. - Threading + pdb — only one thread can hold the
(Pdb)prompt at a time; others keep running. Usethreading.settraceor stop the offending thread first. - stdout/stderr swallowed by frameworks — Streamlit, Celery workers, Gunicorn, etc. redirect streams. Use
remote-pdbor log-based debugging instead.
Real-world recipes
Drop into a debugger only when an env var is set
Production-safe instrumentation: ship the breakpoint() call in your code but make it a no-op unless DEBUG_PDB is set. Cleaner than if DEBUG: import pdb; pdb.set_trace() because Python evaluates breakpoint() lazily through sys.breakpointhook.
# main.py
import os, sys
# Disable breakpoint() globally unless explicitly enabled
if not os.environ.get("DEBUG_PDB"):
sys.breakpointhook = lambda *a, **kw: None
def hot_path(data):
breakpoint() # only fires when DEBUG_PDB=1
return sum(data)
print(hot_path([1, 2, 3]))
python main.py # no pause
DEBUG_PDB=1 python main.py # drops into pdb at the breakpoint()
Output:
6
Reproduce a flaky test under post-mortem
When a pytest run produces a non-deterministic failure, capture it the next time it happens by combining --pdb, -x (stop on first failure), and --lf (re-run last failed). The debugger lands you in the failing assertion's frame with every fixture value still in scope.
pytest -x --pdb -s tests/test_orders.py::test_checkout_total
# fix nothing, just observe; on failure you land at (Pdb)
# next run, repeat only the failures:
pytest --lf -x --pdb -s
Output:
________________ test_checkout_total ________________
> assert order.total == Decimal("19.99")
E AssertionError: assert Decimal('20.00') == Decimal('19.99')
> /home/alice/tests/test_orders.py(42)test_checkout_total()
-> assert order.total == Decimal("19.99")
(Pdb) p order.lines
[Line(sku='SKU-A', qty=2, unit=Decimal('10.00'))]
(Pdb) p order._rounding
'ROUND_HALF_EVEN'
Trace a value back through the call stack
You have a wrong value at the bottom of a deep call chain. Set a conditional breakpoint at the wrong-value site, then walk up until you find the function that produced it.
# pipeline.py
def fetch(url): return {"status": 200, "data": "abc"}
def normalize(payload):
payload["data"] = payload["data"].upper()
return payload
def store(record):
if not record["data"].islower(): # <-- want to know who broke this invariant
breakpoint()
db.insert(record)
store(normalize(fetch("https://api.example.com/x")))
(Pdb) p record
{'status': 200, 'data': 'ABC'}
(Pdb) w
/home/alice/pipeline.py(13)<module>()
-> store(normalize(fetch(...)))
> /home/alice/pipeline.py(9)store()
-> breakpoint()
(Pdb) u
> /home/alice/pipeline.py(13)<module>()
-> store(normalize(fetch(...)))
(Pdb) p normalize.__code__.co_filename + ':' + str(normalize.__code__.co_firstlineno)
'/home/alice/pipeline.py:3'
Remote-debug a container
Inside a long-running container you can't docker exec -it (e.g. Kubernetes pod, CI runner). Use remote-pdb to expose the prompt over a TCP socket and connect with telnet/nc.
pip install remote-pdb
Output: (none — exits 0 on success)
# Inside container app.py
from remote_pdb import RemotePdb
RemotePdb("0.0.0.0", 4444).set_trace()
# From the host
nc 127.0.0.1 4444
Output:
> /app/main.py(15)handler()
-> total = compute(rows)
(Pdb)
Make every TypeError trigger post-mortem
Set sys.excepthook to a wrapper that calls pdb.post_mortem() for one specific exception class. Great during big refactors where one error type keeps recurring across modules.
# bootstrap.py
import pdb, sys, traceback
def excepthook(exc_type, exc, tb):
traceback.print_exception(exc_type, exc, tb)
if issubclass(exc_type, TypeError):
pdb.post_mortem(tb)
sys.excepthook = excepthook
python -c "import bootstrap; '1' + 1"
Output:
Traceback (most recent call last):
File "<string>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
> <string>(1)<module>()
(Pdb)
Print every call into a function (no breakpoint)
sys.settrace is the building block pdb is built on top of. For one-off observability you can write a 10-line tracer that logs every call into a target file without any source modification.
# tracer.py
import sys
def tracer(frame, event, arg):
if event == "call" and frame.f_code.co_filename.endswith("app.py"):
print(f"call -> {frame.f_code.co_name}({frame.f_locals})")
return tracer
sys.settrace(tracer)
import app
app.compute([{"value": 10}, {"value": 20}])
python tracer.py
Output:
call -> compute({'rows': [{'value': 10}, {'value': 20}]})
Step through asyncio code
asyncio schedules coroutines on a single event loop, so s (step) sometimes returns you to the event loop instead of "into" the awaited coroutine. The pragmatic recipe is to set a breakpoint inside the coroutine itself and c to it, rather than trying to single-step across await.
# async_app.py
import asyncio
async def fetch(n):
breakpoint() # pdb lands here directly
await asyncio.sleep(0)
return n * 2
async def main():
results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
print(results)
asyncio.run(main())
python async_app.py
Output:
> /home/alice/async_app.py(5)fetch()
-> await asyncio.sleep(0)
(Pdb) p n
1
(Pdb) c
> /home/alice/async_app.py(5)fetch()
-> await asyncio.sleep(0)
(Pdb) c
> /home/alice/async_app.py(5)fetch()
-> await asyncio.sleep(0)
(Pdb) c
[2, 4, 6]