CertGrid CertGrid
Concepts·Python Automation for IT

Python Script Exit Codes

An unattended script has exactly one way to tell whoever started it what happened: its exit status. Cron mails on non-zero, systemd marks the unit failed, a CI job goes red, `&&` and `||` branch on it. It is one byte - which is the source of the sharpest trap in the domain, because `sys.exit(256)` exits 0.

Scripting Foundations Guide 5 of 39 Beginner

Written against the versions above. The exit-status range is an operating system limit rather than a Python one and is the same on every Unix. `os._exit` skipping `atexit` handlers has always been true. Nothing here is version-sensitive.

Everything on this page runs on the control node. Any machine with Python 3 will do.
Server NameIP AddressOSRolesCPURAMHDD
RUNNER01192.168.0.27Ubuntu 26.04 LTSControl node - every script in this path runs here2 Core4 GB50 GB

Before you start

  1. What the caller sees

    $? in the shell is the previous command's exit status. Zero means success and anything else means failure - that is the whole protocol, and every scheduler and CI system is built on it.

    bash Example session
    cat > ~/auto/status.py <<'PY'import sys count = int(sys.argv[1])print("found", count, "problem(s)")sys.exit(1 if count else 0)PYpython3 ~/auto/status.py 0; echo "  \$? = $?"found 0 problem(s)  $? = 0python3 ~/auto/status.py 3; echo "  \$? = $?"found 3 problem(s)  $? = 1

    Expected result$? = 0 when there were no problems, $? = 1 when there were three.

    Success conditionYou can report success or failure to whatever ran your script.

  2. And how a caller acts on it

    && runs the next command only on success, || only on failure. This is what a cron line, a Makefile and most CI steps are made of.

    bash Example session
    cd ~/auto && python3 status.py 0 && echo "  the && branch ran - nothing to do"found 0 problem(s)  the && branch ran - nothing to docd ~/auto && python3 status.py 2 || echo "  the || branch ran - something to fix"found 2 problem(s)  the || branch ran - something to fix

    Expected resultThe && branch on the clean run, the || branch on the failing one.

    Success conditionYou can see why the status matters more than the output.

  3. The six ways a script can end

    Falling off the end, sys.exit(0), sys.exit(), sys.exit(7), sys.exit("a message"), and an uncaught exception. Statuses only.

    bash Example session
    cat > ~/auto/endings.py <<'PY'import sys how = sys.argv[1] if how == "fall-off":    print("reached the end of the file")elif how == "exit-zero":    sys.exit(0)elif how == "exit-bare":    sys.exit()elif how == "exit-int":    sys.exit(7)elif how == "exit-string":    sys.exit("that did not work")elif how == "raise":    raise ValueError("unhandled")PYcd ~/auto && for how in fall-off exit-zero exit-bare exit-int exit-string raise; do printf '  %-12s ' $how; python3 endings.py $how >/dev/null 2>&1; echo "exit $?"; done  fall-off     exit 0  exit-zero    exit 0  exit-bare    exit 0  exit-int     exit 7  exit-string  exit 1  raise        exit 1

    Expected result0, 0, 0, 7, 1, 1.

    Success conditionYou know what each ending reports.

  4. A string argument is a message, not a code

    sys.exit treats an int as the status and anything else as something to print to stderr before exiting 1.

    bash Example session
    python3 ~/auto/endings.py exit-string; echo "  \$? = $?"that did not work  $? = 1python3 ~/auto/endings.py exit-string 2>/dev/null; echo "  nothing on stdout, and \$? = $?"  nothing on stdout, and $? = 1

    Expected resultThe message, then $? = 1. Redirecting stderr away leaves nothing on stdout, and the status is still 1.

    Success conditionYou can fail with a message in one line.

  5. The range, and where it wraps

    An exit status is one byte. Python passes your integer to the operating system, which keeps the low eight bits and discards the rest.

    Eleven values. Read the last four carefully.

    bash Example session
    cat > ~/auto/wrap.py <<'PY'import sys sys.exit(int(sys.argv[1]))PYcd ~/auto && for n in 0 1 2 42 254 255 256 257 300 512 -1; do printf '  sys.exit(%-5s) -> ' "$n"; python3 wrap.py "$n"; echo "$?"; done  sys.exit(0    ) -> 0  sys.exit(1    ) -> 1  sys.exit(2    ) -> 2  sys.exit(42   ) -> 42  sys.exit(254  ) -> 254  sys.exit(255  ) -> 255  sys.exit(256  ) -> 0  sys.exit(257  ) -> 1  sys.exit(300  ) -> 44  sys.exit(512  ) -> 0  sys.exit(-1   ) -> 255

    Expected result0-255 pass through; 256 becomes 0, 257 becomes 1, 300 becomes 44, 512 becomes 0, and -1 becomes 255.

    Success conditionYou know the usable range.

  6. The bug that hides in that

    Exiting with a count of failures looks entirely reasonable, reads well, and is a trap. Four runs.

    bash Example session
    cat > ~/auto/countbug.py <<'PY'import sys failures = int(sys.argv[1])print("failures:", failures)sys.exit(failures)          # looks reasonable, is a trapPYcd ~/auto && for n in 1 5 255 256; do printf '  %-4s failures -> exit ' $n; python3 countbug.py $n >/dev/null; echo "$?"; done  1    failures -> exit 1  5    failures -> exit 5  255  failures -> exit 255  256  failures -> exit 0

    Expected result1, 5, 255 - then 256 failures exits 0.

    Success conditionYou will not use a count as an exit status.

  7. SystemExit is an exception like any other

    sys.exit() does not stop the interpreter - it raises SystemExit, which then propagates like anything else. So it can be caught.

    bash Example session
    cat > ~/auto/catchexit.py <<'PY'import sys try:    sys.exit(3)except SystemExit as e:    print("caught SystemExit, e.code =", e.code, type(e.code).__name__) print("still running - the exit was swallowed") try:    raise SystemExit("as a string")except SystemExit as e:    print("caught again, e.code =", repr(e.code))PYpython3 ~/auto/catchexit.py; echo "  \$? = $?"caught SystemExit, e.code = 3 intstill running - the exit was swallowedcaught again, e.code = 'as a string'  $? = 0

    Expected resultBoth exits caught, e.code holding 3 and then the string - and the script finishing normally with $? = 0.

    Success conditionYou know sys.exit is a raise, not a halt.

  8. Which is why a bare except swallows your exit

    except Exception: does not catch SystemExit, because it is not an Exception. A bare except: catches everything - including the exit you meant to happen.

    bash Example session
    cat > ~/auto/swallowed.py <<'PY'import sys  def work():    sys.exit(4)  try:    work()except Exception:    print("Exception clause: did NOT catch it, SystemExit is not an Exception")    raisePYpython3 ~/auto/swallowed.py; echo "  \$? = $?"  $? = 4cat > ~/auto/swallowed2.py <<'PY'import sys  def work():    sys.exit(4)  try:    work()except:    print("bare except: caught it, and the exit code is gone") print("carried on regardless")PYpython3 ~/auto/swallowed2.py; echo "  \$? = $?"bare except: caught it, and the exit code is gonecarried on regardless  $? = 0

    Expected resultexcept Exception: lets the exit through, status 4. The bare except: catches it, the script carries on, and the status is 0.

    Success conditionYou can explain a script that fails and reports success.

  9. Cleanup handlers, and the call that skips them

    atexit handlers run on a normal exit and on sys.exit. os._exit terminates the process immediately and runs nothing.

    bash Example session
    cat > ~/auto/cleanup.py <<'PY'import atexitimport osimport sys atexit.register(lambda: print("atexit handler ran")) if sys.argv[1] == "sys":    sys.exit(0)os._exit(0)PYpython3 ~/auto/cleanup.py sys; echo "  \$? = $?"atexit handler ran  $? = 0python3 ~/auto/cleanup.py os; echo "  \$? = $?"  $? = 0

    Expected resultatexit handler ran for sys.exit; nothing at all for os._exit. Both exit 0.

    Success conditionYou know which exit runs your cleanup.

  10. The conventions worth following

    Nothing enforces these, and everything expects them. The last two lines are produced by the shell itself rather than by Python.

    bash Example session
    cat > ~/auto/conventions.py <<'PY'import signalimport sys print("0        success")print("1        general failure")print("2        wrong usage - what argparse exits with")print("126      found but not executable")print("127      command not found")print("128 + N  killed by signal N")print("         SIGINT is", int(signal.SIGINT), "so Ctrl-C is", 128 + int(signal.SIGINT))print("         SIGTERM is", int(signal.SIGTERM), "so a kill is", 128 + int(signal.SIGTERM))PYpython3 ~/auto/conventions.py0        success1        general failure2        wrong usage - what argparse exits with126      found but not executable127      command not found128 + N  killed by signal N         SIGINT is 2 so Ctrl-C is 130         SIGTERM is 15 so a kill is 143nosuchcommand; echo "  command not found -> $?"bash: line 2: nosuchcommand: command not found  command not found -> 127cd ~/auto && touch notexec && chmod -x notexec && ./notexec; echo "  not executable -> $?"bash: line 2: ./notexec: Permission denied  not executable -> 126

    Expected resultThe table, then 127 from a command that does not exist and 126 from one that is not executable.

    Success conditionYou can choose a status that means something to the caller.

Troubleshooting

Official sources