skip to content

Why doesn't sys.excepthook run when a threading.Thread's target raises?

level: middleimportance: should knowfreq 38%

answer

  1. Two stacks, not one
  2. A separate hook lives on threading
  3. One argument, and it names the thread
  4. The process still exits zero
  5. Executors hide it on the future instead

basics

~20 s

A thread's exception never reaches the main thread's stack, so sys.excepthook is never involved. The threading bootstrap catches it and calls threading.excepthook instead, which prints 'Exception in thread ...' and lets the process carry on with an unchanged exit status.

solid answer

~40 s

`sys.excepthook` fires only for an exception that escapes the *main* thread's top frame. A worker thread has its own stack; when its target raises, the code that started it is long gone, so there is nobody to propagate to. The threading bootstrap catches the exception and hands it to `threading.excepthook`, added in **3.8**, which receives a single argument — a `threading.ExceptHookArgs` namedtuple with `exc_type`, `exc_value`, `exc_traceback` and `thread`. The default prints `Exception in thread <name>:` plus the traceback to `sys.stderr`; the thread dies, every other thread keeps running, and the process still exits **0**. That last point is the operational trap: a job can lose a worker and still report success. Assign your own `threading.excepthook` to log the failure and record a failure flag the main thread checks before it reports done.

code

python · 12 lines
python
import sys
import threading

def hook(args):
    print(f"thread {args.thread.name} died: {args.exc_value!r}", file=sys.stderr)

threading.excepthook = hook

t = threading.Thread(target=lambda: 1 / 0, name="reconciler")
t.start()
t.join()
print("main thread alive; process will still exit 0")

go deeper

for a junior

Remember that a worker thread's exception is reported separately and does not stop the program: the message says 'Exception in thread', the main thread carries on, and the exit status is unaffected.

for a middle

Explain the mechanics: two stacks, the bootstrap catching the exception, threading.excepthook taking one args object with exc_type, exc_value, exc_traceback and thread, and threading.excepthook as the default.

for a senior

Demonstrate the operational fix — a hook that both reports and records failure so the job exits non-zero — and know that ThreadPoolExecutor swallows the exception onto the future, which is the silent case that bites in production.

for a principal

Own the guarantee: which failures must fail a job, where thread crash reports land, and whether teams should be starting raw threads at all versus a pooled abstraction whose failures are retrieved and surfaced by construction.

### Why the main-thread hook cannot see it An exception travels up *one* call stack. `threading.Thread.start()` returns immediately; the target then runs on a stack of its own that begins in the threading module's bootstrap, not in the code that called `start()`. When the target raises and nothing in it catches the exception, the exception reaches the bootstrap frame — the top of *that* stack — and stops. The main thread may be somewhere else entirely, or already blocked in `join()`; either way it has no frame in that chain, so `sys.excepthook`, which is the main thread's last-chance step, is never consulted. ### What actually happens Since **3.8** the bootstrap calls `threading.excepthook(args)`. Note the signature difference from `sys.excepthook`: **one** argument, not three. It is a `threading.ExceptHookArgs` namedtuple with four fields: * `exc_type`, `exc_value`, `exc_traceback` — the familiar triple, and * `thread` — the `threading.Thread` object that died, so a report can name it. The default implementation, kept as `threading.__excepthook__`, prints `Exception in thread <name>:` followed by the traceback to `sys.stderr`. Then the thread terminates. Nothing else changes: sibling threads run on, `join()` returns normally, and the interpreter's eventual exit status is whatever the main thread produces — **0** for a program that otherwise finishes. That asymmetry is the whole interview point. Consider a nightly payment reconciliation job that fans work out to a pool of worker threads. One worker hits a partial-failure rollback and raises. The traceback lands in stderr among thousands of log lines, the job's exit status is 0, the scheduler records success, and the discrepancy surfaces three weeks later at the end of a release train. The fix is not to add a `try` around every target body — it is to make failure *visible and fatal at the process level*: ```python import sys, threading failed = threading.Event() def hook(args): failed.set() threading.__excepthook__(args) threading.excepthook = hook # ... start workers, join them ... if failed.is_set(): sys.exit(1) ``` ### Two things the hook is picky about **Do not keep the args object.** It holds the exception instance and the `Thread`, and the exception holds a traceback that holds frames that hold locals — including the thread itself. Storing `args` in a module-level list keeps dead threads and their entire frame state alive; the default hook deliberately drops the reference when it is done. Extract the strings, or the fields you need, and let the object go. **It is global, and it runs on the dying thread.** Your hook body executes on the worker, not the main thread, and it may be running while the interpreter is shutting down — at which point `sys.stderr` can already be closed and module globals torn down. Keep it short, and guard anything that touches I/O. ### The case where neither hook runs A callable submitted to `concurrent.futures.ThreadPoolExecutor` never triggers `threading.excepthook`, because the executor's worker catches the exception and stores it on the `Future` instead. It is re-raised only when someone calls the future's `result()` method, or returned by its `exception()` method. Fire-and-forget `submit()` with no retrieval is therefore *completely* silent — no traceback anywhere. The equivalent discipline is to keep the futures and iterate `concurrent.futures.as_completed`, calling `result()` on each so failures surface. `multiprocessing.pool.Pool` behaves the same way through its result objects. ### Which hook for which failure Complete coverage in a threaded service is: `sys.excepthook` for the main thread, `threading.excepthook` for workers, `sys.unraisablehook` for failures inside finalizers, and the asyncio event loop's exception handler if a loop is running. They are four separate installations, and installing one does nothing for the others. A quick way to check your wiring is to raise deliberately from each context in a smoke test and assert the report reached the sink. ### Before 3.8 Older interpreters printed the same message straight from the bootstrap with no override point, so libraries monkeypatched the thread class's `run` method to wrap it. That workaround is obsolete on any supported version, and a candidate proposing it today is showing their vintage.

  • Where does an exception go if the callable was submitted to a ThreadPoolExecutor instead?
    It never reaches `threading.excepthook`. `concurrent.futures.ThreadPoolExecutor` catches it in the worker and stores it on the `Future`; it is re-raised when someone calls the future's `result()` method. If you submit and never retrieve the result, the failure is silent — no traceback at all. Collect the futures and consume them with `concurrent.futures.as_completed` so every one is inspected.
  • Why do the docs warn against storing the args object your hook receives?
    It carries the exception instance, its traceback and the `Thread` object. A traceback keeps its frames alive, and frames keep their locals alive, so retaining args pins the dead thread and everything it touched — a slow leak that grows with every failure. Pull out the strings or fields you want to log and drop the reference, which is exactly what the default hook does.
  • Does a crashed worker thread change the process exit status?
    No. The thread ends, the rest of the program continues, and the exit status is whatever the main thread produces — zero for a program that completes. If a dead worker should fail the job, record it: set a `threading.Event` from the hook, check it after joining, and call `sys.exit(1)`.

saying these in an interview costs you the question

  • Expects sys.excepthook to fire for any thread
  • Thinks a crashed worker thread makes the process exit non-zero
  • Gives threading.excepthook the same three-argument signature
  • Assumes executor workers print a traceback when they fail
  • Stores the args object, pinning dead threads and their frames
  • Proposes monkeypatching threading.Thread.run as the modern solution

context