Debugging & Performance

Post-Mortem Debugging with pdb.pm()

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.xpdb.post_mortem, pdb.pm, and the sys.last_* attributes are long-standing standard-library features.
  • pytest 5.4+ for --pdb post-mortem on test failure.
  • IPython if you want the %debug magic; 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.

Python
# 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
Bash
> 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:

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

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

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

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

What survives an exception, and where it lives A four-row stack describing the objects that persist after an exception: the exception instance, the traceback chain, each frame with its locals, and the sys.last_traceback reference that post-mortem entry uses. What survives an exception, and where it lives the exception object args, __cause__, __context__ the traceback chain one tb_frame per level, innermost last each frame’s locals still populated, still inspectable sys.last_traceback set at the REPL — what pdb.pm() reads This is also why holding a traceback in a variable keeps every local in that stack alive.
Nothing is unwound in the sense of being discarded: the frames remain reachable through the traceback until it is released.

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

A traceback as a linked list of frame objects that pdb.post_mortem reopens Three stacked frame nodes form a chain. sys.last_traceback names the head, main(), whose f_locals still hold the batch. The tb_next links descend through process() to the deepest frame, transform(), where return row[key] raised KeyError('amount'). pdb.post_mortem enters at that deepest frame, and the up and down commands walk back up the chain toward the caller; every local is still alive because the traceback keeps the frames reachable. A traceback is a chain of frames — post-mortem points pdb at the deepest one caller frame — state preserved raising frame — KeyError unwound here sys.last_traceback the interpreter's top-level capture up down walk the chain batch_job.py:8 · in main() → process(batch) f_locals: batch = [ {'id': 7}, … ] tb_next batch_job.py:31 · in process() → transform(row) f_locals: row = {'id': 7} tb_next batch_job.py:57 · in transform() → return row[key] f_locals: row={'id': 7}, key='amount' ▲ raises KeyError: 'amount' pdb.post_mortem(tb) enters the deepest frame then p / up / down read the preserved locals
A traceback is a linked list of frame objects: 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() raises AttributeError when no unhandled exception has reached the top level — there is no sys.last_traceback. Use pdb.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; continue simply exits the session.
  • python -m pdb -c continue re-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; walk sys.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 await boundaries.

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:

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

Three ways to inspect a failure after the fact A left-to-right comparison of three post-mortem routes: an interactive pdb.pm session locally, a frame dump written to a file in CI, and a remote debugger attached to a long-running process. Three ways to inspect a failure after the fact local: pdb.pm() full interactive session CI: dump frames crash file artefact service: debugpy attach over a port Whatever the route, capture the locals — the traceback alone rarely explains the failure.
All three read the same traceback object; they differ only in whether a human is present when the exception happens.

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.

← Back to Interactive Debugging with pdb and ipdb