Skip to content

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

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.

How each ending of an asyncio script was reported A grid of 9 rows by 3 columns. How each ending of an asyncio script was reported how the script ended status sibling cleanup ran exception raised in main 1 n/a main returned 3 3 n/a sys.exit(4) in main 4 n/a sys.exit(5) in a create_task task 5, plus traceback n/a sys.exit(6) in a TaskGroup child 6, plus traceback no ExceptionGroup from a TaskGroup 1 yes SIGINT (Ctrl-C) -2 (130 in a shell) yes SIGTERM, no handler -15 (143 in a shell) no SIGTERM, handler cancels main 143 yes Python 3.14; negative values are subprocess return codes for death by signal.

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.

Where the exit status is decided A flow of 5 stages. Where the exit status is decided child tasks raise ordinary exceptions TaskGroup cancels siblings, groups errors main() returns an int status asyncio.run cancels and awaits leftovers entry() maps exceptions, sys.exit(code) Decide the status once, after all cleanup, outside the loop.

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.

What status should this ending produce? A decision on How did the run end with 4 outcomes. What status should this ending produce? How did the run end? everything succeeded 0 return from main some items failed 2 (your convention) count failures bad config or a dependency down EX_CONFIG 78 / EX_TEMPFAIL 75 map at the entry point SIGTERM or Ctrl-C 143 / 130 handler cancels main, cleanup runs Only the entry point calls sys.exit.

Verification

An async script exits honestly when:

  • main returns an integer, and sys.exit(asyncio.run(main())) is the only sys.exit.
  • Child tasks raise ordinary exceptions, never SystemExit.
  • Exceptions are mapped to statuses at the entry point, with except* for group-wrapped failures.
  • SIGTERM cancels 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.exit in a TaskGroup child. Measured: status 6, and a sibling's finally never ran.
  • Unhandled SIGTERM. Measured: status -15 and no cleanup.
  • Plain except for TaskGroup failures. Errors arrive wrapped in an ExceptionGroup.
  • Unbounded cleanup. A hang turns a clean stop into a SIGKILL at 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.