Input & UX

Choosing Exit Codes for CLI Tools

Pick exit codes scripts can trust: the 0/1/2 convention, the sysexits.h codes, reserved values above 125, and how to signal distinct failure modes.

Updated

Every time your CLI exits, it hands the shell a single integer. That number is the only thing &&, ||, set -e, and CI gates ever look at — the text you printed is invisible to them. This guide covers which numbers to use: the 0/1/2 baseline, the sysexits.h set and when it earns its keep, the reserved range above 125, and how to model your codes as one IntEnum you can document and test.

TL;DR

  • 0 is success. Everything non-zero is failure. Never exit 0 on an error path.
  • 1 is a general error; 2 is a usage error (bad flags/args) — argparse and Click already use 2 for this, so match them.
  • The sysexits.h codes (EX_USAGE 64, EX_DATAERR 65, …) give richer semantics. Adopt them only if your callers actually branch on them; otherwise they add noise.
  • Values 126, 127, 128, and 128+N are reserved by the shell (not executable, not found, killed by signal N). Stay out of that range.
  • Define your codes once as an IntEnum, document them in --help or the man page, and assert them in tests with CliRunner or subprocess.

Start with 0, 1, and 2

The bedrock convention is older than Python and universally understood:

The three codes every tool needs The baseline exit code convention: zero for success, one for a general failure and two for a usage error. The three codes every tool needs 0 — success the shell reads this as true The work completed. Nothing was printed to stderr that matters. 1 — the work failed general failure The command was used correctly but could not do the job. 2 — you used it wrong usage error Unknown flag, missing argument, bad value. Both argparse and Click already do this. anything beyond these three is an optimisation, not a starting point Ship these three correctly before inventing any codes of your own.
  • 0 — success. The command did what was asked.
  • 1 — a general, catch-all failure. Something went wrong and you have nothing more specific to say.
  • 2 — a usage error: an unknown option, a missing required argument, a malformed value. The user needs to fix the command line, not the world.

That 2-means-usage split is not arbitrary. Both argparse and Click exit 2 when parsing fails, so if you invent your own error handling you should keep 2 reserved for the same meaning. Otherwise a script that treats 2 as "retry with different args" gets confused when your tool returns 2 for a network error.

$ mytool deploy --nonsuch
Usage: mytool deploy [OPTIONS]
Try 'mytool deploy --help' for help.

Error: No such option: --nonsuch
$ echo $?
2

For a great many tools, 0/1/2 is the entire vocabulary you need. Reach for more only when a caller genuinely needs to distinguish failure modes programmatically.

When distinct failure modes earn distinct codes

Sometimes "it failed" is not enough. A backup tool might want CI to retry on a transient network failure but hard-stop on corrupted data. That is a real reason to hand out different numbers:

raise SystemExit(3)   # network unreachable — safe to retry
raise SystemExit(4)   # data integrity check failed — do NOT retry

Now a wrapper script can branch:

mytool backup
case $? in
  0) echo "ok" ;;
  3) echo "transient — retrying"; retry ;;
  4) echo "corruption — paging oncall"; page ;;
  *) echo "unknown failure"; exit 1 ;;
esac

The test for whether a custom code is worth it is simple: will a caller ever behave differently because of it? If yes, define it. If the only consumer is a human reading the message, a plain 1 with good error text is enough — see friendly error messages and tracebacks for making that text actionable.

The sysexits.h codes

BSD's <sysexits.h> defines a standard set of codes in the 6478 range, meant to give failures a shared vocabulary across tools:

The sysexits.h codes worth using A table of conventional sysexits exit codes: usage, data format, no input, unavailable, software error and configuration error. The sysexits.h codes worth using Code Name Means 64 EX_USAGE the command line was wrong 65 EX_DATAERR the input data was malformed 66 EX_NOINPUT an input file is missing or unreadable 69 EX_UNAVAILABLE a service the tool needs is down 70 EX_SOFTWARE an internal error — a bug 78 EX_CONFIG the configuration is wrong Adopt these only if your users script against them — an undocumented 69 is no better than a 1.
CodeNameMeaning
64EX_USAGECommand used incorrectly (bad args)
65EX_DATAERRInput data was incorrect
66EX_NOINPUTAn input file did not exist or was unreadable
69EX_UNAVAILABLEA required service is unavailable
70EX_SOFTWAREInternal software error
73EX_CANTCREATCannot create an output file
74EX_IOERRAn I/O error occurred
77EX_NOPERMPermission denied
78EX_CONFIGSomething is misconfigured

Python does not ship these as constants, so define the ones you use:

EX_USAGE = 64
EX_DATAERR = 65
EX_NOINPUT = 66
EX_CONFIG = 78

When to bother: adopt sysexits.h if your tool lives in an ecosystem that already reads them — mail delivery agents, some init systems, tools invoked by xargs pipelines that inspect specific codes. For a typical developer CLI, most callers only distinguish 0 from non-zero, and the 6478 numbers are more obscure than a documented 1/2/3 scheme of your own. Consistency and documentation beat conformance to a table nobody reads. Pick one convention and hold it across every subcommand.

Reserved values above 125

Some codes are not yours to assign — the shell claims them, and reusing them creates ambiguity:

The ranges you must not use Exit code ranges reserved by the shell: 126 for a command that cannot execute, 127 for command not found, and 128 plus the signal number for a terminated process. The ranges you must not use yours to define 0–125 safe Your own codes live here. Document every one you use. 126 shell The command was found but is not executable. 127 shell Command not found — a PATH problem, not your program. 128 + N signals Killed by signal N. 130 is Ctrl-C, 137 is a hard kill. returning 130 yourself makes a clean exit look like an interrupt Exit codes above 125 already mean something to the shell; borrowing them makes debugging scripts miserable.
  • 126 — the command was found but is not executable (permission problem).
  • 127 — command not found.
  • 128 — invalid argument to exit (e.g. a non-integer).
  • 128 + N — the process was killed by signal N. So 130 = 128 + 2 (SIGINT, a Ctrl-C), 137 = 128 + 9 (SIGKILL), 143 = 128 + 15 (SIGTERM).
  • Above 255 — impossible. Exit status is 8 bits, so codes wrap modulo 256: exit(256) reports as 0, exit(257) as 1. A code over 255 is a silent bug.

The practical rule: keep your own meaningful codes in the 1125 range. If a caller sees 130, they should be able to conclude "someone hit Ctrl-C," not "the backup's canary check failed." Matching Ctrl-C to 130 is a nicety worth implementing:

except KeyboardInterrupt:
    raise SystemExit(130)   # 128 + SIGINT

One IntEnum as the single source of truth

Scattering magic numbers like raise SystemExit(4) across a codebase is how a 4 comes to mean two different things in two commands. Centralize them:

from enum import IntEnum

class ExitCode(IntEnum):
    OK = 0
    ERROR = 1          # general failure
    USAGE = 2          # bad invocation
    NETWORK = 3        # transient, retryable
    DATA = 4           # corrupt input, do not retry
    CONFIG = 5         # misconfiguration

    def __str__(self) -> str:            # so f-strings show the number
        return str(self.value)

Because IntEnum is an int, you can hand it straight to sys.exit or return it from main:

import sys

def main() -> ExitCode:
    if not config_ok():
        print("error: config invalid; see 'mytool config --check'", file=sys.stderr)
        return ExitCode.CONFIG
    if not reachable():
        print("error: registry unreachable", file=sys.stderr)
        return ExitCode.NETWORK
    do_work()
    return ExitCode.OK

if __name__ == "__main__":
    sys.exit(main())      # IntEnum → int, cleanly

The enum becomes the one place you look to answer "what does 5 mean?" — and the names make the call sites self-documenting. This pairs naturally with the top-level error boundary described in the error handling and exit codes overview, where each caught exception maps to one ExitCode.

Documenting your codes

An exit code nobody can look up might as well be random. Publish the table where callers will find it — in --help epilog text, a man page, or the README:

import click

EPILOG = """\
Exit codes:
  0  success
  1  general error
  2  usage error
  3  network error (retryable)
  4  data error (do not retry)
  5  configuration error
"""

@click.command(epilog=EPILOG)
def cli() -> None:
    ...

Keeping the table next to the IntEnum — ideally generated from it — means the docs cannot drift from the code.

Testing exit codes

An exit code is a promise; test it like one. With Click's CliRunner you get the code without spawning a process:

from click.testing import CliRunner
from mytool.cli import cli

def test_bad_flag_is_usage_error() -> None:
    result = CliRunner().invoke(cli, ["--nonsuch"])
    assert result.exit_code == 2

def test_missing_config_returns_config_code() -> None:
    result = CliRunner().invoke(cli, ["run"])
    assert result.exit_code == ExitCode.CONFIG

For an end-to-end check that includes your real entry point and sys.exit, drive it as a subprocess:

import subprocess

def test_network_failure_exit_code() -> None:
    proc = subprocess.run(
        ["mytool", "backup", "--registry", "http://127.0.0.1:1"],
        capture_output=True,
        text=True,
    )
    assert proc.returncode == 3
    assert "unreachable" in proc.stderr

Note the second test also asserts the message went to stderr, not stdout — the two promises a good failure keeps. For a broader look at exercising a CLI in tests, the entry-point mechanics are covered in best practices for Python CLI entry points.

Production notes

  • Wrapping bites silently. sys.exit(256) exits 0. If codes are computed, clamp or assert they stay in 0125.
  • Click and Typer own some codes. Click exits 2 on parse errors and 1 for ClickException; Abort exits 1. Don't reassign those meanings in the same app.
  • set -e and pipelines. In a | b, the shell reports b's code by default. Callers who need a's status use set -o pipefail — document that your meaningful codes may be masked mid-pipe.
  • Cross-platform. Signal-derived codes (130, 143) are a Unix convention; Windows reports different values. Keep the codes you rely on in the low range for portability.
  • CI gates. Most CI systems treat any non-zero as a failed step. If you use codes like 3 for "retryable," make sure the wrapper — not the raw CI step — is what interprets them.

Frequently asked questions

Does an exit code above 1 mean anything special to the shell?

Between 0 and 125 the meaning is entirely yours — the shell only cares that 0 is success and anything else is failure. From 126 upward the values are already spoken for: 126 means the command was found but could not be executed, 127 means it was not found at all, and 128 + N means the process was killed by signal N (130 for Ctrl-C, 137 for SIGKILL). Returning one of those yourself makes a normal failure look like a crash to anyone reading a CI log.

Should I use the sysexits.h codes?

Only if your users script against them. The sysexits values (64 for a usage error, 65 for bad data, 69 for an unavailable service, 78 for a configuration problem) are a genuine convention on BSD-derived systems, and they are a good vocabulary to borrow when you need more than pass/fail. But an undocumented 69 tells a reader no more than an undocumented 1. The value comes from documenting the codes and keeping them stable across releases, not from the numbers themselves.

How do I keep exit codes consistent across a large CLI?

Define them once in an IntEnum and never write a bare integer anywhere else. The enum gives each code a name, a docstring and a single place to review when you add one, and because IntEnum members are integers you can pass them straight to sys.exit or typer.Exit. Commands then raise a domain exception, and one top-level error boundary maps that exception to the enum member — which means no command function has to know the numbers at all.

Is it safe to change an exit code in a new release?

Treat it as a breaking change. Exit codes are part of a CLI's public interface in exactly the way flags are: a script somewhere is testing for exit code 2, and changing what that means will silently alter a conditional in someone's pipeline. Add a new code rather than repurposing an old one, and if a change is unavoidable, bump the major version and say so in the changelog.

What exit code should my tool use when the user presses Ctrl-C?

Let it exit with 130, which is what 128 + SIGINT works out to, and make sure you do not swallow the interrupt. Catch KeyboardInterrupt only to clean up (close a file, remove a partial download, restore the cursor), then re-raise it or call sys.exit(130). Printing a traceback for a deliberate interrupt looks like a crash, and exiting 0 makes a cancelled run look successful to the calling script.