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:

bash
# 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:

text
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).

bash
# 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.

CommandAliasMeaning
help [cmd]hList commands or get help for one
nextnExecute the current line; step over calls
stepsStep into a function call
continuecResume until the next breakpoint or exit
returnrContinue until the current function returns
until [LINE]untContinue until line number LINE (or past current loop)
list [first[,last]]lShow 11 lines around the current line
longlistllShow the whole current function/frame
print EXPRpPrint the value of an expression
pp EXPRppPretty-print (uses pprint.pprint)
whatis EXPRShow the type of an expression
wherew / btShow the stack trace
up [N]uMove N frames up in the stack
down [N]dMove N frames down in the stack
`break [FILE:LINEFUNC]`b
tbreak ...Temporary breakpoint (auto-clear on hit)
clear [BPNUM]clRemove breakpoints
condition BPNUM EXPRMake a breakpoint conditional
ignore BPNUM COUNTSkip the next COUNT hits
commands BPNUMAttach a script to a breakpoint
jump LINEjJump to another line (no execution in between)
display EXPRRe-show EXPR at every stop in this frame
interactDrop into a full Python REPL with the current scope
argsaPrint current frame's arguments
quitqAbandon 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.

python
# 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}])
bash
python app.py

Output:

text
> /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.

bash
# 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.

text
(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.

text
(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*

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.

text
(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.

python
# 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()
bash
# Option A: re-run the script under pdb and continue until it crashes
python -m pdb -c continue crash.py

Output:

text
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.

python
# 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
bash
DEBUG_PM=1 python main.py

Output:

text
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.

python
# loop.py
def process(items):
    for i, item in enumerate(items):
        result = item ** 2
        print(i, result)

process(range(1000))
bash
python -m pdb loop.py

Output:

text
(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.

text
(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.

bash
# 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.

ToolWhat it adds
ipdbIPython-style prompt: tab completion, syntax-colored tracebacks, multi-line input
pdbppDrop-in pdb replacement with sticky mode (full function visible while stepping), smarter list, and tab completion
pudbCurses-based full-screen TUI: variables pane, stack pane, breakpoints pane — keyboard-driven
debugpyVS Code's DAP server; attach the editor for graphical debugging
bash
# 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:

text
> <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.

bash
# 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)

json
// .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

  1. Forgetting -s under pytest — pytest captures stdout, so the (Pdb) prompt is invisible. Always pair --pdb with -s.
  2. breakpoint() left in committed code — add a ruff rule (T100 — "trace found") or a pre-commit hook to fail on accidental breakpoint() calls.
  3. continue inside a generator / async function — pdb resumes only the current task; other awaitables stay paused. Step with n and watch the event loop, not c.
  4. Mutating locals doesn't persist after c — in CPython, writes to locals() in a function frame are discarded. Mutate dicts, lists, and objects (which are references) instead of rebinding names.
  5. p vs pp confusionp uses repr(), which can be a single 4000-character line for a big dict. pp invokes pprint.pprint with width 80. Reach for pp whenever the object is bigger than a one-liner.
  6. 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. Use gdb with the CPython debug symbols for those.
  7. jump only works in the bottom frame and not across blocks — you can't jump out of an except clause, into a loop, or backwards over a return. Useful but narrow.
  8. Remote / SSH sessions need a TTYbreakpoint() under nohup or in a Docker container without -it will hang silently. Use docker exec -it or set PYTHONBREAKPOINT=remote_pdb.set_trace (pip install remote-pdb) to listen on a socket.
  9. Threading + pdb — only one thread can hold the (Pdb) prompt at a time; others keep running. Use threading.settrace or stop the offending thread first.
  10. stdout/stderr swallowed by frameworks — Streamlit, Celery workers, Gunicorn, etc. redirect streams. Use remote-pdb or 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.

python
# 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]))
bash
python main.py                  # no pause
DEBUG_PDB=1 python main.py      # drops into pdb at the breakpoint()

Output:

text
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.

bash
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:

text
________________ 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.

python
# 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")))
text
(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.

bash
pip install remote-pdb

Output: (none — exits 0 on success)

python
# Inside container app.py
from remote_pdb import RemotePdb
RemotePdb("0.0.0.0", 4444).set_trace()
bash
# From the host
nc 127.0.0.1 4444

Output:

text
> /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.

python
# 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
bash
python -c "import bootstrap; '1' + 1"

Output:

text
Traceback (most recent call last):
  File "<string>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
> <string>(1)<module>()
(Pdb)

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.

python
# 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}])
bash
python tracer.py

Output:

text
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.

python
# 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())
bash
python async_app.py

Output:

text
> /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]