ValueError: signal only works in main thread (tested)

ValueError: signal only works in main thread of the main interpreter means something called signal.signal() outside the main thread. We reproduced it and its asyncio variant on Python 3.13.5, then measured the standard fix.

1. signal.signal() called from a thread

import signal, threading
def handler(signum, frame):
    print("got", signum)
def setup():
    try:
        signal.signal(signal.SIGTERM, handler)
    except ValueError as e:
        print(threading.current_thread().name, "->", type(e).__name__, e)
t = threading.Thread(target=setup)
t.start(); t.join()
signal.signal(signal.SIGTERM, handler)
print("MainThread -> OK")

Output:

Thread-1 (setup) -> ValueError signal only works in main thread of the main interpreter
MainThread -> OK

Measured on Python 3.13.5: installing a handler from any thread other than the main one raises ValueError: signal only works in main thread of the main interpreter. The same call in the main thread works. You usually hit this indirectly: a library that sets a handler (a timeout decorator using signal.alarm, a CLI framework, a server) is called from a web server's worker thread, a thread pool, or a GUI callback.

2. asyncio's add_signal_handler: a different error

import asyncio, signal
async def main():
    loop = asyncio.get_running_loop()
    def setup():
        try:
            loop.add_signal_handler(signal.SIGTERM, print, "stop")
        except Exception as e:
            print("add_signal_handler in a thread ->", type(e).__name__, e)
    await asyncio.to_thread(setup)
    loop.add_signal_handler(signal.SIGTERM, print, "stop")
    print("add_signal_handler in the loop's thread -> OK")
asyncio.run(main())

Output:

add_signal_handler in a thread -> RuntimeError set_wakeup_fd only works in main thread of the main interpreter
add_signal_handler in the loop's thread -> OK

Measured: loop.add_signal_handler() from a thread raises RuntimeError mentioning set_wakeup_fd, not the ValueError above, but it's the same rule. Register signal handlers in the thread that runs the event loop, and run that loop in the main thread.

3. Fix: handle the signal in main, tell the threads

import os, signal, threading, time
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda signum, frame: stop.set())   # main thread only

def worker():
    while not stop.is_set():
        time.sleep(0.05)
    print("worker saw the stop flag, cleaning up")

t = threading.Thread(target=worker)
t.start()
threading.Timer(0.3, os.kill, (os.getpid(), signal.SIGTERM)).start()
t.join()
print("exited cleanly")

Output:

worker saw the stop flag, cleaning up
exited cleanly

Measured: the main thread installs the handler, which only sets a threading.Event; the worker checks the event and shuts down cleanly when SIGTERM arrives. Signal handlers always run in the main thread anyway, so this is the pattern the rule pushes you towards. For Ctrl+C specifically, see our KeyboardInterrupt not working guide.

Frequently asked questions

What does ValueError: signal only works in main thread of the main interpreter mean?

signal.signal() was called from a thread other than the main thread. We measured it from a threading.Thread on Python 3.13.5; the same call in the main thread works.

Why do I get set_wakeup_fd only works in main thread?

That's asyncio's version of the same rule: loop.add_signal_handler() called from another thread. We measured it raising RuntimeError.

How do I stop worker threads when the process gets SIGTERM?

Install the handler in the main thread and have it set a threading.Event the workers check. We measured a worker exiting cleanly 0.3 s after SIGTERM.

Can a thread receive a signal in Python?

No. Python runs signal handlers in the main thread only, so handlers must be installed there and pass the news to other threads.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5 on Linux: signal.signal() from a thread and from main, asyncio add_signal_handler from a thread, and a SIGTERM-to-Event shutdown with a worker thread. Every block was run verbatim and its output pasted from that run. PlainHeaders is our extension; everything else here is built-in Python.