Why does `except Exception` not catch SystemExit from sys.exit()?
answer
- Look one level above Exception
- Two branches, errors and control flow
- A bare except is not the same clause
- Shutdown is not an error to handle
- Cleanup still runs on the way out
basics
~20 sSystemExit inherits directly from BaseException, not from Exception, so except Exception deliberately lets it pass. That keeps a broad error handler from swallowing a requested shutdown. A bare except: means except BaseException and does swallow it.
solid answer
~50 s`SystemExit` sits beside `Exception` in the hierarchy, both inheriting from `BaseException`. That placement is intentional: `except Exception` is meant to catch *errors*, and a requested process exit is not an error, so the standard broad handler lets `SystemExit` fly past and the process still ends. A bare `except:` is shorthand for `except BaseException`, so it catches `SystemExit` too — which is exactly how a retry loop or a plugin dispatcher accidentally becomes un-killable, quietly turning a deliberate `sys.exit(1)` into another lap of the loop. The same applies to `contextlib.suppress(BaseException)` and to any handler written against `BaseException`. Cleanup still runs either way: `finally` blocks and context-manager `__exit__` methods execute while `SystemExit` unwinds. When you genuinely want to intercept an exit — testing a CLI's status, for instance — catch `SystemExit` by name and read its `code` attribute.
code
python · 15 linesimport sys
def run():
try:
sys.exit(2)
except Exception:
print("Exception handler ran")
print("still running")
try:
run()
except SystemExit as exc:
print("caught at top level, code =", exc.code)go deeper
Remember the shape of the hierarchy: SystemExit inherits from BaseException, not from Exception, so the everyday except Exception does not catch it. Also remember that a bare except: catches everything and should be avoided.
Explain why the split exists — broad handlers should catch errors, not shutdown requests — and show that a bare except: means except BaseException. Be able to catch SystemExit deliberately and read its code attribute when testing an entry point.
Diagnose the real symptom: a service that will not stop because some worker or plugin dispatcher catches too broadly. Show the fix (narrow to Exception, or log and re-raise) and explain that cleanup paths run normally while SystemExit unwinds.
Set the standard across a codebase: where broad handlers are permitted, what must always be re-raised, and how shutdown is expected to travel from a signal handler to the entry point. The goal is that every service in the fleet stops predictably when asked.
## The placement, and why it was chosen Python's built-in exceptions all descend from `BaseException`, but they split immediately into two families. Almost everything a program should handle — `ValueError`, `OSError`, `KeyError` — lives under `Exception`. A small set of control-flow signals hang off `BaseException` directly instead, and `SystemExit` is one of them, alongside `KeyboardInterrupt` and `GeneratorExit`. ```pycon >>> SystemExit.__mro__ (<class 'SystemExit'>, <class 'BaseException'>, <class 'object'>) ``` The split exists so that the idiomatic broad handler works. `except Exception` is the way to say "catch any error this operation might raise, log it, keep serving". If `SystemExit` were an `Exception`, every such handler in every library on the stack would also intercept a deliberate shutdown, and a process asked to exit would keep running for reasons its author never wrote down. Making the exit signal a sibling of `Exception` rather than a child of it means the two intentions — "handle errors" and "let shutdown through" — are expressed by the same line of code. ## What that looks like in practice Consider a receiver that processes queued webhook deliveries and wraps each one so a bad payload cannot take the service down: ```python import sys def handle(payload): if payload == "shutdown": sys.exit(0) raise ValueError("bad payload") for payload in ("junk", "shutdown"): try: handle(payload) except Exception as exc: print("logged and continuing:", exc) ``` The `ValueError` is caught and the loop continues; the `sys.exit(0)` is not caught, propagates out of the loop, and ends the process with status 0. That is the designed behaviour, and it is why `except Exception` is the correct broad handler. ## The bare except trap Change that handler to a bare `except:` and the behaviour inverts. A bare `except:` clause is defined as catching `BaseException`, so it catches `SystemExit` as well: ```python import sys for attempt in range(2): try: sys.exit(1) except: print("swallowed the exit on attempt", attempt) print("process still alive") ``` This script prints both attempts and then exits 0. Everything the author intended — abort now, tell the supervisor status 1 — was discarded. In a long-running service the symptom is worse than a wrong status: the process becomes hard to stop, because each exit attempt is absorbed and the loop takes another lap. The same trap appears in less obvious spellings: `except BaseException:` written by someone who wanted "catch everything"; `contextlib.suppress(BaseException)`; and a plugin or task dispatcher that catches broadly so one misbehaving handler cannot kill the worker. In every case the fix is the same — handle `Exception`, and if you must handle `BaseException` for logging, re-raise afterwards: ```python try: do_work() except BaseException: log_crash() raise ``` ## Cleanup still happens A common misconception is that `SystemExit` skips cleanup because it is "special". It does not. It is an ordinary exception object travelling an ordinary unwinding path: `finally` blocks run, `with` statements call `__exit__`, and a generator being closed sees its cleanup. The only thing special about `SystemExit` is what happens if nobody catches it — instead of printing a traceback and exiting 1 like any other uncaught exception, the interpreter exits quietly with the status carried on the exception's `code` attribute. ## Catching it on purpose Sometimes you *want* to intercept the exit. The usual case is testing an entry point: you want to assert that a bad argument produces status 2 without your test runner's process dying. Catch it by name and read `code`: ```python import sys import unittest def main(argv): if len(argv) < 2: sys.exit(2) return 0 class CliTest(unittest.TestCase): def test_missing_argument_exits_2(self): with self.assertRaises(SystemExit) as caught: main(["tool"]) self.assertEqual(caught.exception.code, 2) ``` Note that `code` is whatever was passed — an `int`, `None`, or a string message — not a normalized status. A test asserting `code == 1` against a `sys.exit("message")` call will fail even though the process would have exited 1. ## The rule to state in an interview Use `except Exception` for error handling; it is broad enough for real errors and narrow enough to let shutdown through. Never write a bare `except:` — it is a `BaseException` handler wearing a disguise. Catch `SystemExit` only where intercepting an exit is the actual intent, and re-raise anything you caught only to observe.
- Which other built-in exception classes sit outside Exception, and why does that grouping matter?`KeyboardInterrupt` and `GeneratorExit` are the other two that inherit from `BaseException` directly. The grouping is a contract: everything under `Exception` is an error you may reasonably handle and continue from, while these three are control-flow signals — stop the process, stop now, stop this generator. `except Exception` therefore handles errors without ever interfering with a shutdown or a cancellation.
- Do finally blocks and context-manager __exit__ methods still run while SystemExit propagates?Yes. `SystemExit` unwinds exactly like any other exception, so every `finally` block and every `__exit__` on the way out executes, in the usual innermost-first order. Only `os._exit()` skips them, because it terminates the process at the C level without unwinding at all. The unique part of `SystemExit` is only its endgame: uncaught, it exits with its `code` instead of printing a traceback.
- A worker in a receiver catches BaseException so one bad handler cannot kill it. What is the fix?Narrow it to `except Exception`, which already covers every error a handler can raise while leaving shutdown signals alone. If the broad catch exists for crash logging rather than recovery, keep `except BaseException` but re-raise immediately after logging, so the exit or interrupt continues on its way. Silently absorbing `BaseException` produces a process that logs beautifully and refuses to stop.
except Exception is a net strung across the road to stop runaway vehicles; SystemExit is the driver going home at the end of the shift, and the net is hung deliberately high enough to let them through.
saying these in an interview costs you the question
- Says SystemExit is a subclass of Exception
- Treats a bare except: as equivalent to except Exception
- Claims finally blocks are skipped when SystemExit propagates
- Believes sys.exit() cannot be intercepted at all
- Recommends a bare except: as defensive coding
- Assumes the process still exits after SystemExit is caught