Capturing Unhandled Exceptions and Warnings in Python

Everything on the error path assumes an except block ran. This page covers the failures where none did: a crash on the main thread, a worker thread that vanishes, a task nobody awaited, a DeprecationWarning that goes to stderr and is lost the moment the container is replaced. It is for engineers who have a working log pipeline and want to be sure nothing routes around it. It builds on the exception and traceback logging guide, part of the Python logging fundamentals and structured data section.

There are four separate mechanisms, and installing one of them tells you nothing about the other three.

Four independent hooks, one log stream Four sources of un-handled failure on the left, each with its own interpreter mechanism in the middle, all converging on the application logger on the right. A crash on the main thread is delivered to sys.excepthook. A thread whose target raises is delivered to threading.excepthook, which is a separate attribute that sys.excepthook never covers. An exception stored on an abandoned asyncio task is delivered to the event loop's exception handler when the task is garbage collected. A warning that passes the warnings filters is delivered to the py.warnings logger only once logging.captureWarnings has been enabled. Without each hook installed, that source's default destination is stderr, which in a container means the record is lost as soon as the process is replaced. install one and the other three still go to stderr main thread crashes the ordinary traceback thread target raises the worker just stops task never awaited result read by nobody warning fires deprecation, resource sys.excepthook receives (type, value, tb) threading.excepthook receives ExceptHookArgs loop.set_exception_handler receives a context dict captureWarnings(True) routes to py.warnings your logger one format, one sink, one retention policy uninstalled, each source falls back to stderr — which in a container is gone the moment the process is replaced
Four mechanisms, no shared plumbing. A service that installs only sys.excepthook is silent about every thread it starts.

Prerequisites

Standard library only. The one pin below is for the asyncio-heavy example at the end.

pip install "uvicorn>=0.30.0,<1.0.0"
export PYTHONWARNINGS=default::DeprecationWarning   # Python hides these outside __main__

Implementation

Step 1 — Replace sys.excepthook. It receives the three-element triple directly, which is exactly what exc_info accepts, so forwarding is a one-liner. Delegate KeyboardInterrupt to the original hook: an operator pressing Ctrl-C does not want a CRITICAL record and a stack trace.

import logging
import sys

logger = logging.getLogger("service")

def _excepthook(exc_type, exc_value, exc_tb):
    if issubclass(exc_type, KeyboardInterrupt):
        sys.__excepthook__(exc_type, exc_value, exc_tb)   # keep Ctrl-C quiet
        return
    logger.critical("uncaught exception", exc_info=(exc_type, exc_value, exc_tb))

sys.excepthook = _excepthook

CRITICAL is deliberate. The process is unwinding, nothing downstream will handle this, and the record must survive a level filter someone raised to ERROR during an incident. The reasoning behind that mapping is in log levels and severity mapping.

Step 2 — Replace threading.excepthook. This is a different attribute with a different signature: it receives a single ExceptHookArgs namedtuple carrying exc_type, exc_value, exc_traceback and thread. sys.excepthook is never consulted for a thread, which is why a dead worker thread is one of the most common silent failures in Python services.

import threading

def _thread_excepthook(args: threading.ExceptHookArgs) -> None:
    if issubclass(args.exc_type, SystemExit):
        return
    logger.critical(
        "uncaught exception in thread",
        exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
        extra={"thread_name": args.thread.name if args.thread else "unknown"},
    )

threading.excepthook = _thread_excepthook

Step 3 — Install the asyncio loop handler. An exception on a task you never awaited is stored on the task object and surfaces only when it is collected, through the loop's exception handler. The default handler prints Task exception was never retrieved to stderr. Replace it, and pull the exception out of the context dict.

import asyncio

def _loop_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
    exc = context.get("exception")
    logger.error(
        context.get("message", "unhandled error in the event loop"),
        exc_info=exc if exc is not None else False,
        extra={"asyncio_future": repr(context.get("future") or context.get("task"))},
    )

async def main() -> None:
    asyncio.get_running_loop().set_exception_handler(_loop_exception_handler)
    ...

Note the timing: this fires at garbage-collection time, potentially long after the request that created the task has finished. Any request-scoped contextvars are gone by then, which is why the extra above records the future's repr rather than relying on ambient context. The pattern for keeping context alive across task boundaries is in using contextvars for request tracing.

Step 4 — Redirect warnings. logging.captureWarnings(True) monkey-patches warnings.showwarning so anything that passes the filters is emitted as a WARNING record on the py.warnings logger. It does not change which warnings fire — that is still the filter's job, and Python ignores DeprecationWarning outside __main__ by default, which is why a library deprecation can vanish for a year.

import warnings

logging.captureWarnings(True)
warnings.simplefilter("default")                     # let deprecations through
warnings.filterwarnings("ignore", category=ResourceWarning, module="botocore")
logging.getLogger("py.warnings").setLevel(logging.WARNING)
Two independent gates between a warning and your log A warning travels through two separate stages. The first is the filter stage, controlled by warnings.simplefilter, warnings.filterwarnings and the PYTHONWARNINGS environment variable, which decides whether the warning fires at all; by default DeprecationWarning is ignored outside the main module, so it never reaches the second stage. The second is the routing stage, controlled by logging.captureWarnings: when it is off, showwarning prints to stderr, and when it is on the warning becomes a WARNING record on the py.warnings logger and follows the same handlers, formatters and retention as everything else. Enabling only the routing stage leaves deprecations silently filtered out, and enabling only the filter stage sends them to stderr where a replaced container loses them. turning on captureWarnings is only half of it warn() fires in a library you use gate 1 · the filters simplefilter · filterwarnings PYTHONWARNINGS default: deprecations ignored gate 2 · the routing captureWarnings(False) → stderr captureWarnings(True) → py.warnings a WARNING record like any other routing on, filters untouched nothing to route — the deprecation never fired the usual "we enabled it and saw nothing" outcome both on py.warnings records with your formatter and sinks filter the noisy third-party ones by module, not globally the two gates are independent — set the filter to decide what matters, and the routing to decide where it lands
Most teams enable the routing and conclude their dependencies emit no deprecations. The filter stage is where they were dropped.

Step 5 — Make sure the last record is flushed. logging.shutdown() is registered with atexit when the logging module is imported, and atexit runs hooks in reverse order of registration. Anything you register after configuring logging therefore runs before shutdown, which is the order you want for a queue drain.

import atexit
import logging.config

logging.config.dictConfig(CONFIG)     # logging imported and configured first
atexit.register(listener.stop)        # registered later → runs earlier → drains first
Why registration order decides whether the last record survives The atexit stack drawn as a list of hooks with the order they run. Python's logging module registers logging.shutdown the moment it is first imported, which in almost every service is very early. atexit runs hooks in reverse order of registration, so anything registered later runs earlier. A queue drain registered after dictConfig therefore runs before logging.shutdown, and the buffered records reach their handlers while those handlers are still open. A drain registered before logging was configured — for example at the top of a module imported before the logging setup — runs after shutdown has already closed the handlers, so the final records are formatted into a closed stream and lost. The crash record written by sys.excepthook is the one most often lost this way, because it is written at the very end of the process's life. atexit runs hooks in reverse registration order drain registered after dictConfig 1. listener.stop() — drains the queue 2. logging.shutdown() — closes handlers the handlers are still open when the drain runs the CRITICAL crash record lands register the drain after logging is configured drain registered before logging was set up 1. logging.shutdown() — closes handlers 2. listener.stop() — drains into closed handlers the records are formatted and then discarded the log ends mid-incident, every time logging registers its hook when it is first imported
The last record of an incident is written at the very end of the process's life, which is precisely when this ordering decides whether it exists.

Configuration options

Option Applies to Default Recommended
sys.excepthook main-thread crash prints to stderr log at CRITICAL, delegate KeyboardInterrupt
threading.excepthook thread target prints to stderr log at CRITICAL with thread_name
loop.set_exception_handler abandoned task prints to stderr log at ERROR with the future repr
logging.captureWarnings warnings module False True
warnings.simplefilter which warnings fire default (deprecations hidden) "default", then silence noisy modules by name
logging.raiseExceptions handler errors True False in production
atexit registration order shutdown flush reverse of registration register the drain after dictConfig

Verification

Exercise all four paths in one script and confirm four records arrive on the same sink with the same format.

import asyncio, logging, sys, threading, warnings

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
logger = logging.getLogger("service")
install_hooks()                                # steps 1-4 from above

def boom():
    raise RuntimeError("thread died")

threading.Thread(target=boom, name="worker-1").start()

async def orphan():
    raise ValueError("nobody awaited me")

async def main():
    asyncio.get_running_loop().set_exception_handler(_loop_exception_handler)
    asyncio.create_task(orphan())
    await asyncio.sleep(0.1)

asyncio.run(main())
warnings.warn("old api", DeprecationWarning)
raise SystemError("main thread")

Expected Output:

CRITICAL service uncaught exception in thread
Traceback (most recent call last):
  File "verify.py", line 11, in boom
RuntimeError: thread died
ERROR service Task exception was never retrieved
Traceback (most recent call last):
  File "verify.py", line 16, in orphan
ValueError: nobody awaited me
WARNING py.warnings verify.py:24: DeprecationWarning: old api
CRITICAL service uncaught exception
Traceback (most recent call last):
  File "verify.py", line 25, in <module>
SystemError: main thread

Four failures, four records, one stream. Without the hooks, the first three print to stderr in three different formats and the fourth is the only one your log backend ever sees.

Common mistakes

The hook is installed but never fires

Error signature: crashes still print a bare traceback to stderr. Root cause: something replaced sys.excepthook after you did — pytest, a debugger, sentry_sdk, or a framework's own error handling — or the failure was in a thread. Remediation: install the hooks as the last step of startup, assert sys.excepthook is _excepthook in a smoke test, and install threading.excepthook alongside it. If a chained hook is what you want, capture the previous value and call it after logging.

The final record never reaches the file

Error signature: the log ends mid-incident; the CRITICAL record from the excepthook is missing. Root cause: logging.shutdown() closed the handlers before the queue listener drained, because the drain was registered with atexit before logging was configured. Remediation: register the drain after dictConfig, or call listener.stop() explicitly inside the excepthook before returning.

captureWarnings produces nothing

Error signature: no py.warnings records ever appear, in a codebase that is definitely using deprecated APIs. Root cause: the filter stage, not the routing stage — DeprecationWarning is ignored by default outside __main__. Remediation: set warnings.simplefilter("default") or PYTHONWARNINGS=default::DeprecationWarning, then silence individual noisy modules with filterwarnings rather than turning the category off again.

What the hooks cannot cover

Installing all four hooks closes the Python-level escape routes, and it is worth being explicit about what remains open, because the remaining cases are the ones that produce a container that vanished with no log line at all.

Signals. A SIGKILL — from an out-of-memory kill, from a container runtime that ran out of termination patience, from an operator — cannot be intercepted at all. SIGTERM can be, and should be: a handler that logs the signal and then flushes is the difference between "the pod restarted" and "the pod restarted because the orchestrator asked it to at 03:14". Keep the handler tiny, because it runs between bytecodes on the main thread and anything that acquires a lock there can deadlock against whatever held it when the signal arrived.

import signal

def _on_term(signum, frame):
    logger.warning("received signal", extra={"signal": signal.Signals(signum).name})
    listener.stop()                       # drain, then let the default behaviour run
    raise SystemExit(0)

signal.signal(signal.SIGTERM, _on_term)

Interpreter-level crashes. A segmentation fault in a C extension, a stack overflow, or an abort from a native library ends the process without running any Python code. Nothing in the logging system helps here; faulthandler.enable() does, because it installs a native handler that writes a Python traceback to a file descriptor from inside the signal handler itself.

import faulthandler
import sys

faulthandler.enable(file=sys.stderr, all_threads=True)

Exceptions during interpreter shutdown. Once shutdown begins, module globals are being torn down, and an exception raised in a __del__ method or an atexit hook may find that the objects it needs are already None. Those produce the characteristic Exception ignored in: messages that go to stderr and cannot be routed anywhere, because the logging module itself may already be partially dismantled. The remedy is not to catch them but to avoid needing to: keep __del__ methods free of logging, and do cleanup in explicit close() methods called from a context manager.

Work that never started. A thread that could not be created, a task that was cancelled before running, a subprocess that failed to exec — none of these raise where you can see them. The general remedy is a heartbeat: a component that is supposed to be running should say so periodically, and its absence should alert. That is a monitoring design rather than an exception-handling one, and it is the only thing that catches a component which silently never started.

Failure Caught by What you get
SIGTERM a signal handler a record naming the signal, plus a clean drain
SIGKILL nothing the absence of a shutdown record — which is itself the signal
segfault in an extension faulthandler a native-level traceback on stderr
exception during shutdown nothing routable Exception ignored in: on stderr
a component that never started a heartbeat and an alert absence, detected deliberately

The practical conclusion is that the four hooks plus a SIGTERM handler and faulthandler cover everything a Python process can report about its own death. What remains is covered by noticing that something stopped reporting, which is a different mechanism and belongs in the metrics pipeline rather than in the logging one.

Frequently Asked Questions

Why did my sys.excepthook not fire?

Three common reasons. The exception happened in a thread, which uses threading.excepthook instead. Something else replaced sys.excepthook after you did — a debugger, a test runner, or a framework's own handler. Or the process was killed by a signal rather than an exception, in which case no Python-level hook runs at all and you need a signal handler.

Does logging.captureWarnings replace my warnings filter?

No. captureWarnings only changes where a warning goes once it has passed the filters — it is routed to the py.warnings logger at WARNING level instead of being printed. Which warnings fire at all is still decided by warnings.simplefilter and the PYTHONWARNINGS environment variable, and Python ignores DeprecationWarning outside __main__ by default.

Should the excepthook log at ERROR or CRITICAL?

CRITICAL. By the time the hook runs the process is unwinding and nothing else will handle the failure, which is exactly what CRITICAL means in the severity mapping. It also survives any level filter set to ERROR during an incident when someone turns the volume down.

Why is my final crash record missing from the log file?

logging.shutdown runs as an atexit hook registered when the logging module is first imported, and atexit runs hooks in reverse registration order. If your handler queue is drained by a hook registered earlier, shutdown may close the handlers before the drain happens. Register the drain after logging is configured, or call listener.stop() explicitly from the excepthook itself.