Exiting Async Scripts with the Right Status Code¶
Shells, cron, CI runners and container orchestrators judge a script by one number: its exit status. Async scripts have more ways to end than synchronous ones — an exception in the main coroutine, a failure in a child task, an ExceptionGroup from a TaskGroup, Ctrl-C, SIGTERM from an orchestrator — and they do not all produce the status or the cleanup you would expect. Each case was run as a subprocess on Python 3.14 and its status recorded. An exception in main exited 1; returning 3 through sys.exit(asyncio.run(main())) exited 3; sys.exit(5) inside a task created with create_task exited 5 but printed a Task exception was never retrieved traceback; sys.exit(6) inside a TaskGroup child exited 6 — and the sibling task's finally block never ran. SIGINT produced status -2 (130 in a shell) after running cleanup; an unhandled SIGTERM produced -15 with no cleanup at all; with a handler that cancelled the main task, the same signal produced 143 with cleanup. This guide makes every ending produce a deliberate status.
Prerequisites¶
- Python 3.11+; standard library only.
- Signal handling in asyncio, from handling SIGTERM in asyncio services.
- The topic overview, Async Scripts & CLIs.
1. Return the status from main¶
asyncio.run returns whatever the main coroutine returns, so the cleanest design returns an integer and passes it to sys.exit at the top level:
import asyncio
import sys
EX_OK, EX_PARTIAL, EX_FAILED = 0, 2, 1
async def main() -> int:
results = await process_all()
failed = sum(1 for r in results if isinstance(r, BaseException))
if failed == len(results):
return EX_FAILED
return EX_PARTIAL if failed else EX_OK
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Measured: return 3 from main exited with status 3; an uncaught RuntimeError from main exited with status 1 and a traceback. Deciding the status in one place — after everything has finished and been cleaned up — avoids most of the surprises below. Partial failure deserves its own code: a batch script that processed 9,990 of 10,000 records should not look identical to one that processed none, nor to one that processed all.
Verify: each of success, partial failure and total failure produces a different status in a test run.
2. Never call sys.exit inside a task¶
sys.exit raises SystemExit, a BaseException, and asyncio treats it as something that must stop the loop immediately rather than an ordinary task failure:
async def child() -> None:
sys.exit(6) # do not do this
async def main() -> None:
async with asyncio.TaskGroup() as tg:
tg.create_task(child())
tg.create_task(slow()) # has a finally block that prints "cleanup ran"
Measured: status 6, a long traceback on stderr beginning Task exception was never retrieved, and no "cleanup ran" — the sibling was never given the chance to run its finally. The same with a plain create_task child exited 5 and printed the same traceback; main never resumed. Contrast an ordinary exception in a TaskGroup child: the group cancelled the sibling, its finally ran, and the script exited 1 with an ExceptionGroup traceback. Raise an ordinary exception — or record the failure and return — and let main choose the status.
Verify: grep -rn "sys.exit" --include=*.py finds calls only at the top-level entry point.
3. Map exceptions to statuses at the boundary¶
Some failures deserve specific statuses — configuration errors, unavailable dependencies, invalid input — and the conventional values from BSD's sysexits.h are widely understood. Catch at the entry point, after the loop has finished:
import os
class ConfigError(Exception): ...
class Unavailable(Exception): ...
def entry() -> int:
status = 1
try:
try:
status = asyncio.run(main())
except* Unavailable as eg: # bare, or inside a TaskGroup's group
print(f"dependency unavailable: {eg.exceptions[0]}", file=sys.stderr)
status = os.EX_TEMPFAIL # 75: cron and systemd may retry later
except ConfigError as exc:
print(f"config error: {exc}", file=sys.stderr)
status = os.EX_CONFIG # 78
return status
if __name__ == "__main__":
sys.exit(entry())
Measured: a missing setting exited 78, an Unavailable raised directly from main exited 75, the same error raised inside a TaskGroup child also exited 75, and an unmapped ValueError exited 1 with its traceback. except* matters because failures inside a TaskGroup arrive wrapped in an ExceptionGroup; a plain except Unavailable would miss them and the script would exit 1 with a raw group traceback. Two syntax rules shape the code: one try cannot mix except and except* clauses, hence the nesting, and return is not allowed inside an except* block, hence the status variable. A bare ConfigError passed straight through the inner except* to the outer handler. The pattern for matching inside groups is in handling specific errors with except*. Keep the mapping at the boundary, outside the loop: by the time asyncio.run returns or raises, every task has been cancelled and awaited, so cleanup has already happened.
Verify: a failure of each mapped type, raised from a TaskGroup child, produces its mapped status.
4. Handle SIGTERM so cleanup runs and the status is honest¶
Orchestrators stop containers and jobs with SIGTERM. Without a handler, Python's default action terminates the process immediately:
async def main() -> int:
loop = asyncio.get_running_loop()
task = asyncio.current_task()
loop.add_signal_handler(signal.SIGTERM, task.cancel)
try:
await run_job()
except asyncio.CancelledError:
return 128 + signal.SIGTERM # 143: "terminated by SIGTERM"
return 0
Measured: with no handler the subprocess return code was -15 and the finally block in the running task never printed; with the handler it was 143 and cleanup ran. SIGINT behaves differently out of the box — asyncio.run already installs a handler that cancels the main task, so Ctrl-C ran cleanup and then re-raised KeyboardInterrupt, giving -2 (130 in a shell). Returning 128 + signum after a handled signal keeps the convention that tells a supervisor the job was stopped rather than failed. The full shutdown sequence for long-running services is in graceful shutdown and signal handling.
Verify: kill -TERM on a running script produces status 143 and its cleanup output.
5. Bound the time cleanup may take¶
A cancelled script that hangs during cleanup is worse than one that exits uncleanly: the orchestrator will SIGKILL it after its grace period, and the status becomes -9. Give cleanup its own deadline, shorter than the grace period:
async def run_job() -> None:
resources = await open_resources()
try:
await do_work(resources)
finally:
try:
async with asyncio.timeout(5): # under the orchestrator's grace period
await resources.aclose()
except TimeoutError:
print("cleanup timed out; exiting anyway", file=sys.stderr)
Measured with a cleanup step that hung and a 1-second budget: after SIGTERM the script printed its timeout message at 1.00 s and exited with status 143 at 1.01 s — asyncio.timeout still works inside a finally that runs during cancellation. A finally block runs while the task is being cancelled, and an await inside it can itself be cancelled or hang; preventing CancelledError leaks in cleanup covers the details. Kubernetes' default grace period is 30 seconds and systemd's default stop timeout is 90; a cleanup budget well under either keeps the status under the script's control.
Verify: a script whose cleanup hangs still exits within the budget with its intended status.
Verification¶
An async script exits honestly when:
mainreturns an integer, andsys.exit(asyncio.run(main()))is the onlysys.exit.- Child tasks raise ordinary exceptions, never
SystemExit. - Exceptions are mapped to statuses at the entry point, with
except*for group-wrapped failures. SIGTERMcancels the main task, cleanup runs within a deadline, and the status is 143.
Diagnostic Hook: run the script under a wrapper in CI that sends SIGTERM after a few seconds and asserts status 143 and the presence of the cleanup log line. Status -15 (or 143 without the log line) means the handler is missing and production stops are skipping cleanup.
Pitfalls & edge cases¶
sys.exitin a TaskGroup child. Measured: status 6, and a sibling'sfinallynever ran.- Unhandled
SIGTERM. Measured: status -15 and no cleanup. - Plain
exceptfor TaskGroup failures. Errors arrive wrapped in anExceptionGroup. - Unbounded cleanup. A hang turns a clean stop into a
SIGKILLat the end of the grace period.
Frequently Asked Questions¶
How do I set the exit code of an asyncio script?
Return an integer from the main coroutine and call sys.exit(asyncio.run(main())). Returning 3 produced status 3 in testing; an uncaught exception produced 1.
Can I call sys.exit() inside an asyncio task?
Avoid it. In a TaskGroup child it exited with the requested status but skipped a sibling task's finally block and printed a 'Task exception was never retrieved' traceback. Raise an exception or return a status instead.
What exit code does an asyncio script return on Ctrl-C or SIGTERM?
Ctrl-C ran cleanup and exited with -2 (130 in a shell). SIGTERM without a handler killed the process with -15 and no cleanup; with a handler that cancels the main task, the script can run cleanup and return 143.
Which exit codes should a batch script use?
0 for success, a distinct code such as 2 for partial success, and the sysexits.h values where they fit — EX_CONFIG (78) for configuration errors and EX_TEMPFAIL (75) for temporary failures that cron or systemd may retry.
Related¶
- Async Scripts & CLIs — up to the topic overview.
- Preventing overlapping runs of async cron scripts — where EX_TEMPFAIL is used.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.