A batch job crashes once at 3 a.m. with a KeyError deep in a call stack, and you cannot reproduce it interactively. Re-running with a breakpoint is hopeless if the input is gone. Post-mortem debugging reopens the exact frame where the exception was raised, with every local still intact, so you inspect the state at the moment of failure rather than guessing from a traceback.
Prerequisites
- Python 3.x —
pdb.post_mortem,pdb.pm, and thesys.last_*attributes are long-standing standard-library features. - pytest 5.4+ for
--pdbpost-mortem on test failure. - IPython if you want the
%debugmagic; the underlying mechanism is identical. - The command vocabulary from interactive debugging with pdb and ipdb — post-mortem drops you into the same prompt.
Solution
When an unhandled exception reaches the top level, the interpreter stores its traceback on sys.last_traceback (with sys.last_value and sys.last_type). pdb.pm() opens a post-mortem session on that stored traceback; pdb.post_mortem(tb) does the same for any traceback object you hand it.
# In an interactive session or REPL after a crash:
import pdb
def load(config):
return config["timeout"] # KeyError if the key is missing
load({}) # raises KeyError: 'timeout', prints a traceback
pdb.pm() # reopen the failing frame from sys.last_traceback
> example.py(4)load()
-> return config["timeout"]
(Pdb) p config # the exact argument that caused the crash, still alive
{}
(Pdb) up # walk toward the caller if needed
(Pdb) p sys.last_value
KeyError('timeout')
When you catch the exception yourself, grab the traceback off the exception object and pass it explicitly — this works inside a script where sys.last_traceback is never set:
import pdb
import sys
def run():
try:
risky()
except Exception:
# __traceback__ holds the frames; post_mortem reopens the deepest one
pdb.post_mortem(sys.exc_info()[2])
def risky():
data = [1, 2, 3]
return data[99] # IndexError
For an unattended script, launch it under pdb with -c continue. The script runs at full speed; only if it crashes does pdb take over at the failing frame:
python -m pdb -c continue batch_job.py
# ... normal output ...
# Traceback ... then automatically:
# > batch_job.py(57)transform()
# -> return row[key]
# (Pdb)
Inside IPython or Jupyter, %debug is the one-liner equivalent of pdb.pm() — it opens a post-mortem prompt on the last exception. %pdb on arms it so every subsequent uncaught exception drops you in automatically.
# IPython
In [1]: load({}) # raises KeyError
In [2]: %debug # post-mortem prompt at the failing frame
pytest exposes the same behaviour with --pdb: on any test failure it opens a post-mortem prompt in the failing frame with the assertion's exception live.
pytest tests/test_config.py --pdb # drop into post-mortem at the point of failure
This is the natural follow-on to --trace (break at test start) covered in the parent guide; use --pdb when you want to inspect after the failure rather than step into the test. When you already know the failure is coming and want to stop before it — on the precise iteration that corrupts state — reach instead for conditional breakpoints in pdb. If the failure is intermittent across runs, pair post-mortem triage with the rerun analysis in debugging flaky tests with pytest-rerunfailures.
The traceback object is what makes this possible: it keeps every frame alive after the exception has propagated.
Why this works
A traceback object is a linked list of frame objects, each carrying its locals and globals at the instant the exception unwound. Post-mortem debugging does not re-execute anything — it points the pdb command loop at the deepest frame in that captured chain, so p, up, and down read the preserved state. Because the frames are kept alive by the traceback reference, the state survives until that reference is dropped; pdb.pm() simply reuses the interpreter's own top-level capture (sys.last_traceback).
sys.last_traceback names the head, and tb_next descends to the deepest frame where the exception was raised. pdb.post_mortem points its command loop at that frame; up and down walk back toward the caller, and every local is still alive.Because those frame objects stay reachable through the traceback, a post-mortem session sees the same locals whether the process is still interactive or the exception was captured and stashed for later inspection — the mechanism is identical to how a memory profile with tracemalloc can surface objects a stray traceback reference is keeping alive.
Edge cases and failure modes
pdb.pm()raisesAttributeErrorwhen no unhandled exception has reached the top level — there is nosys.last_traceback. Usepdb.post_mortem(tb)with an explicit traceback inside scripts.- The traceback's frames pin their locals in memory; holding a traceback reference (or an exception via
except ... as e) for a long time can leak large objects — clear it when done. - Post-mortem state is read-only in spirit: you can evaluate expressions, but you cannot resume execution from a post-mortem frame;
continuesimply exits the session. python -m pdb -c continuere-runs the program; if the crash depends on external state that has changed, it may not reproduce.- Chained exceptions (
raise ... from) land you on the most recent one; walksys.last_value.__cause__or__context__to inspect the original. - A crash inside a coroutine captures the awaiting frames like any other, but the surrounding event loop may already be torn down by the time you land — post-mortem the traceback the loop reported, and see debugging async code and event loops for reading a stack that unwound across
awaitboundaries.
Post-mortem in places without a terminal
pdb.pm() assumes an interactive prompt, which a CI job, a container, or a production worker does not have. Three alternatives give you the same information without one.
Dump the frames to a file. The traceback module can render every frame with its locals, which is usually enough to avoid needing the session at all. Hook it into the exception path and the artefact lands next to the failure:
import sys, traceback
def dump_frames(exc: BaseException, path: str = "crash.txt") -> None:
tb = exc.__traceback__
with open(path, "w") as fh:
traceback.print_exception(type(exc), exc, tb, file=fh)
fh.write("\n--- locals, innermost frame last ---\n")
while tb is not None:
frame = tb.tb_frame
fh.write(f"\n{frame.f_code.co_filename}:{tb.tb_lineno} "
f"in {frame.f_code.co_name}\n")
for name, value in list(frame.f_locals.items())[:40]:
fh.write(f" {name} = {value!r:.200}\n")
tb = tb.tb_next
Truncating each repr matters: a frame holding a large dataframe will otherwise write megabytes, and the useful values are lost in the noise.
Use pytest's own post-mortem. In a test suite, pytest --pdb drops into the debugger at the point of failure, and --pdbcls=IPython.terminal.debugger:TerminalPdb swaps in the richer prompt. Neither is usable in CI, but both are the fastest route locally — and --pdb -x stops at the first failure so the session opens on the frame you care about.
Attach a remote debugger. When the process must keep running, debugpy listens on a port and lets an editor attach to the live interpreter, including after an exception if the code catches it and calls debugpy.breakpoint(). That is heavier than the file dump and needs a network path into the process, so keep it for reproducible staging failures rather than one-off production incidents.
The general rule: capture state at the moment of failure, in whatever form the environment allows. A crash file with forty locals per frame answers most questions a live session would have, and unlike a session it can be attached to the incident ticket.
Frequently Asked Questions
What is the difference between pdb.pm() and pdb.post_mortem()?pdb.post_mortem(tb) starts a post-mortem session on a traceback you pass it. pdb.pm() is a convenience wrapper that calls post_mortem on sys.last_traceback, the traceback of the most recent unhandled exception stored by the interpreter, so you can debug a crash that already printed.
Why is sys.last_traceback sometimes missing?
The interpreter only sets sys.last_traceback, sys.last_value, and sys.last_type when an unhandled exception reaches the top level in interactive mode. Inside a script that caught the exception, or in a fresh process, the attribute does not exist, so pdb.pm() raises AttributeError.
How do I get a post-mortem prompt automatically when a script crashes?
Run python -m pdb -c continue your_script.py. The -c continue runs the script normally, and if it raises an unhandled exception pdb drops into a post-mortem prompt at the failing frame instead of exiting.
Does holding a traceback leak memory?
Yes, and it is a common cause of slow growth in exception-heavy code. A traceback references every frame, and each frame references its locals, so storing exc.__traceback__ on an object keeps the entire stack alive. Format the traceback to a string and discard the object, or clear it explicitly with exc.__traceback__ = None once the diagnostic has been captured.
Related
- Setting conditional breakpoints in pdb — stop before the crash on the exact iteration, the complement to post-mortem.
- Interactive debugging with pdb and ipdb — the full command vocabulary the post-mortem prompt inherits.
- Debugging async code and event loops — post-mortem a stack that unwound across
await. - Memory profiling with tracemalloc — find the objects a lingering traceback reference is pinning in memory.
- Debugging flaky tests with pytest-rerunfailures — triage the intermittent failure that only reproduces under
--pdbonce in a while.
← Back to Interactive Debugging with pdb and ipdb