skip to content

What is sys.excepthook, and when does CPython call it?

level: juniorimportance: must knowfreq 44%

answer

  1. Last chance before the process ends
  2. A replaceable callable living on sys
  3. Three arguments: type, value, traceback
  4. sys.__excepthook__ still holds the default
  5. Main thread only; exit code stays non-zero

basics

~20 s

sys.excepthook is the callback CPython runs when an exception escapes the main thread's top level. The default prints a traceback to sys.stderr and the process exits non-zero. Replace it to log or report the crash.

solid answer

~40 s

When an exception propagates all the way out of the main thread, CPython does not simply die: it calls `sys.excepthook(exc_type, exc_value, exc_traceback)`. The default implementation, still reachable as `sys.__excepthook__`, prints the formatted traceback to `sys.stderr`, after which the interpreter exits with status 1. Assigning your own callable to `sys.excepthook` is the standard way to route unhandled crashes into a logger or an error tracker. It is a *reporting* hook, not a recovery mechanism — returning from it does not resume the program, and the process still exits. Two limits matter in practice: it only covers the main thread (worker threads go through `threading.excepthook`), and it is per interpreter process, so a child process started by `multiprocessing` must install it in its own startup path. `SystemExit` never reaches it; the interpreter handles that case itself.

code

python · 13 lines
python
import sys
import logging

logging.basicConfig(level=logging.ERROR)

def report(exc_type, exc_value, exc_tb):
    logging.getLogger("crash").error(
        "unhandled exception", exc_info=(exc_type, exc_value, exc_tb)
    )
    sys.__excepthook__(exc_type, exc_value, exc_tb)

sys.excepthook = report
raise RuntimeError("reconciliation batch aborted")

go deeper

for a junior

Recall the shape: an uncaught exception in the main thread reaches sys.excepthook, which prints the traceback to stderr and the process exits non-zero. Know that you replace it by assignment and that sys.excepthook keeps the original.

for a middle

Be ready to explain the three arguments, that returning from the hook does not resume anything, that SystemExit never reaches it, and how to pass the triple straight to logging as exc_info while still calling the default hook.

for a senior

Show that one hook is not global coverage: name the thread, unraisable and asyncio counterparts, and explain how a hook survives into worker processes under spawn or forkserver. Expect a question about a crash report that never arrived.

for a principal

Own the policy: where crash reporting is installed for every entry point the organisation ships, what is scrubbed from a report before it leaves the process, and why a last-chance hook is a diagnostic of last resort rather than a substitute for handling failures where they occur.

### The call path Every Python program runs a piece of code at the very top of the stack — the `__main__` module, a `-c` string, or a REPL statement. If an exception propagates past that frame, there is no caller left to catch it, so CPython performs its last-chance step: it looks up `sys.excepthook` and calls it with three arguments, the exception **class**, the exception **instance**, and the traceback object — the same triple `sys.exc_info()` would have produced inside an `except` block. The default value of that attribute prints `Traceback (most recent call last):` and the formatted frames to `sys.stderr`, then the interpreter finalizes and exits with status **1**. The pristine default is kept in `sys.__excepthook__`, which is what you call when you want to add reporting *and* keep the familiar console output. ### Replacing it Installing a hook is a plain assignment: ```python import sys, logging def report(exc_type, exc_value, exc_tb): logging.getLogger("crash").critical( "unhandled", exc_info=(exc_type, exc_value, exc_tb) ) sys.__excepthook__(exc_type, exc_value, exc_tb) sys.excepthook = report ``` Because `logging` accepts exactly that three-tuple as `exc_info`, wiring the hook into structured logs or an error-reporting client is a couple of lines. This is how a batch job gets a crash record instead of a stderr blob that nobody reads. ### What it cannot do The hook runs *after* stack unwinding has already finished, so it cannot retry the failed operation, cannot swallow the failure, and cannot change the exit status by returning. If you need a different exit code, raise or call `sys.exit()` from inside the hook, or better, wrap `main()` in a `try`/`except` and keep the hook for the truly unexpected. Use `try`/`except` for errors you anticipate; the hook is for the ones you did not. If your replacement hook itself raises, CPython does not lose the original failure: it prints `Error in sys.excepthook:` with your hook's traceback, then `Original exception was:` with the real one. That fallback is a safety net, not a design — keep hook bodies small and defensive, because they run when the process is already in a bad state (a memory error, a half-finalized interpreter, a closed stderr). ### The exceptions it does not see * **`SystemExit`.** `sys.exit()` raises it, and the interpreter's top level treats it as a request to exit with the given status rather than an error; the hook is skipped. `KeyboardInterrupt`, by contrast, *is* an ordinary exception and does reach the hook. * **Non-main threads.** A failure inside a thread's target is caught by the threading bootstrap and reported through `threading.excepthook` (added in **3.8**) instead. * **Finalizers.** An exception raised inside `__del__`, a weakref callback, or during garbage collection cannot propagate at all; it goes to `sys.unraisablehook` (also **3.8**). * **asyncio tasks.** An exception nobody retrieves from a task is reported by the event loop's own exception handler, not by `sys.excepthook`. So "install one hook and I have global coverage" is false: complete coverage in a real service means the main-thread hook, the thread hook, the unraisable hook and the loop handler. ### Process scope `sys.excepthook` is state of one interpreter in one process. A child created by `multiprocessing` re-imports your module under the **spawn** and **forkserver** start methods, so any hook installed at import time is re-installed there, while a hook installed inside `if __name__ == "__main__":` is not. This matters more since **3.14**, where the default start method on Unix (other than macOS) changed from `fork` to `forkserver`; code that silently relied on inheriting the parent's hook by forking stops reporting. Put the installation in an importable module-level function that the child's entry point also calls. ### The REPL At an interactive prompt the same hook runs for each uncaught exception, but the interpreter keeps going rather than exiting — which is why a debugger-style hook like `pdb.post_mortem` is a classic thing to bind here.

  • Does sys.excepthook run when a program calls sys.exit()?
    No. `sys.exit()` raises `SystemExit`, and the interpreter's top level special-cases that exception: it exits with the requested status without consulting the hook. `KeyboardInterrupt` is different — it is an ordinary exception and does reach the hook, which is why a hook that unconditionally reports 'crash' will report every Ctrl-C as one.
  • What happens if your replacement hook raises an exception itself?
    CPython falls back to the default: it prints `Error in sys.excepthook:` followed by your hook's traceback, then `Original exception was:` with the real failure, and still exits non-zero. Nothing is lost, but the output is doubled and confusing, so keep the hook body small and wrap risky work — a network call to an error tracker, say — in its own try/except.
  • Does installing the hook in a parent process cover its worker processes?
    Only if the child runs the installation itself. Each process has its own interpreter state, and under the spawn and forkserver start methods the child re-imports your modules rather than copying the parent's memory. Install the hook from module-level code, or from an initializer the child entry point calls, rather than from a branch only the parent executes.

It is the black-box recorder, not the ejector seat: it writes down what happened on the way down, but the flight is already over.

saying these in an interview costs you the question

  • Says sys.excepthook catches exceptions raised in every thread
  • Believes the hook can resume the program after the failure
  • Expects the hook to run for sys.exit() and SystemExit
  • Uses the hook instead of try/except for expected errors
  • Assumes worker processes inherit a hook installed at runtime
  • Confuses it with an atexit callback that always runs

context