KeyboardInterrupt / Ctrl+C not working in Python (tested)
When Ctrl+C doesn't stop a Python program, or except KeyboardInterrupt never runs, there are four usual causes. We sent a real SIGINT to each one on Python 3.13.5 and measured what happens.
How we tested
Each case runs a small script in a subprocess, sends it SIGINT (what Ctrl+C sends) after 0.5 s, and reports whether it exited within 2 s. The helper, run.py:
# helper: run a script, send SIGINT (Ctrl+C) after 0.5s, report what happened
import subprocess, sys, signal, time
def ctrl_c(src, wait=0.5):
p = subprocess.Popen([sys.executable, "-c", src], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(wait); p.send_signal(signal.SIGINT)
try:
out, _ = p.communicate(timeout=2)
status = f"exited {p.returncode}"
except subprocess.TimeoutExpired:
p.kill(); out, _ = p.communicate()
status = "STILL RUNNING 2s after Ctrl+C (had to kill it)"
lines = [l for l in out.splitlines() if l and not l.startswith(" ")]
return status + "".join(f"\n {l}" for l in lines[-2:])
1. A bare except: swallows Ctrl+C
from run import ctrl_c
src = """
import time
while True:
try:
time.sleep(0.1)
except: # bare except also catches KeyboardInterrupt
pass
"""
print(ctrl_c(src))
src2 = src.replace("except:", "except Exception:")
print(ctrl_c(src2))
Output:
STILL RUNNING 2s after Ctrl+C (had to kill it)
exited -2
Traceback (most recent call last):
KeyboardInterrupt
Measured on Python 3.13.5: with except: the loop ignores Ctrl+C and keeps running; with except Exception: it stops at once. KeyboardInterrupt derives from BaseException, not Exception, exactly so that except Exception lets it through. except BaseException: swallows it the same way a bare except: does.
2. Threads: the traceback prints but the program keeps running
from run import ctrl_c
src = """
import threading, time
def work():
while True: time.sleep(0.1)
t = threading.Thread(target=work)
t.start()
t.join()
"""
print("non-daemon thread:", ctrl_c(src))
fixed = """
import threading, time
stop = threading.Event()
def work():
while not stop.is_set(): time.sleep(0.1)
t = threading.Thread(target=work)
t.start()
try:
while t.is_alive(): t.join(0.2)
except KeyboardInterrupt:
stop.set(); t.join(); print("stopped cleanly")
"""
print("Event + join(timeout):", ctrl_c(fixed))
Output:
non-daemon thread: STILL RUNNING 2s after Ctrl+C (had to kill it)
Traceback (most recent call last):
KeyboardInterrupt
Event + join(timeout): exited 0
stopped cleanly
Measured: with a non-daemon worker thread, Ctrl+C does raise KeyboardInterrupt in the main thread (the traceback prints), but the process then waits for the worker forever. Signals are only delivered to the main thread. Fix it by giving workers a stop Event and setting it on KeyboardInterrupt, as above. Marking the thread daemon=True also lets the process exit (we measured exit -2 with the traceback), but kills the worker mid-task with no cleanup.
3. asyncio: your coroutine sees CancelledError
from run import ctrl_c
src = """
import asyncio
async def main():
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
print("main got CancelledError, not KeyboardInterrupt")
raise
try:
asyncio.run(main())
except KeyboardInterrupt:
print("asyncio.run raised KeyboardInterrupt")
"""
print(ctrl_c(src))
Output:
exited 0
main got CancelledError, not KeyboardInterrupt
asyncio.run raised KeyboardInterrupt
Measured: inside asyncio.run(), Ctrl+C cancels the main task, so the coroutine gets asyncio.CancelledError, not KeyboardInterrupt, and asyncio.run() then raises KeyboardInterrupt to the caller. An except KeyboardInterrupt inside the coroutine never runs; put cleanup in except asyncio.CancelledError (and re-raise) or in finally.
4. SIGINT set to ignored
from run import ctrl_c
src = """
import signal, time
signal.signal(signal.SIGINT, signal.SIG_IGN) # something set SIGINT to ignored
time.sleep(5)
"""
print(ctrl_c(src))
Output:
STILL RUNNING 2s after Ctrl+C (had to kill it)
Measured: if anything sets SIGINT to SIG_IGN (a library, or a parent process that started yours with it ignored, since ignored signals are inherited), Ctrl+C does nothing. Restore it with signal.signal(signal.SIGINT, signal.default_int_handler), or stop the process with kill (SIGTERM).
Frequently asked questions
Why is KeyboardInterrupt not caught or not working in Python?
Most often a bare except: or except BaseException: swallows it. We measured a loop with except: ignoring Ctrl+C while except Exception: stopped immediately, on Python 3.13.5.
Why doesn't Ctrl+C stop my Python program with threads?
Signals go to the main thread only; it raises KeyboardInterrupt, then the interpreter waits for non-daemon threads. We measured the traceback printing while the process kept running. Use a stop Event, or daemon=True.
How do I catch KeyboardInterrupt in asyncio?
Inside the coroutine you get asyncio.CancelledError; asyncio.run() raises KeyboardInterrupt to its caller. We measured both. Do cleanup in except CancelledError or finally.
Does except Exception catch KeyboardInterrupt?
No. KeyboardInterrupt inherits from BaseException. We measured except Exception: letting Ctrl+C stop the loop.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5 on Linux, by sending SIGINT to a subprocess and checking whether it exited within 2 s: bare except vs except Exception, a non-daemon thread vs a stop Event (and daemon=True, checked separately), asyncio.run with CancelledError, and SIGINT set to SIG_IGN. Every block was run verbatim and its output pasted from that run. Windows console behaviour was not tested. PlainHeaders is our extension; everything else here is the standard library.