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
- Python3.14.4
- Control nodeUbuntu 26.04 LTS
- Managed hostsRHEL 10.0
- requests2.34.2
- paramiko5.0.0
- pytest9.1.1
- PyYAML6.0.3
- boto3 / botocore1.43.78
- TimeAbout 16 min
- Reviewed24 August 2026
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.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| RUNNER01 | 192.168.0.27 | Ubuntu 26.04 LTS | Control node - every script in this path runs here | 2 Core | 4 GB | 50 GB |
Before you start
- guide 4 - argparse's exit 2 is the reason this matters.
- Familiarity with
$?in a shell helps and is not required.
-
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) $? = 1Expected result
$? = 0when there were no problems,$? = 1when there were three.Success conditionYou can report success or failure to whatever ran your script.
-
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 fixExpected resultThe
&&branch on the clean run, the||branch on the failing one.Success conditionYou can see why the status matters more than the output.
-
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 1Expected result0, 0, 0, 7, 1, 1.
Success conditionYou know what each ending reports.
-
A string argument is a message, not a code
sys.exittreats anintas 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 $? = 1Expected 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.
-
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 ) -> 255Expected 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.
-
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 0Expected result1, 5, 255 - then 256 failures exits 0.
Success conditionYou will not use a count as an exit status.
-
SystemExit is an exception like any other
sys.exit()does not stop the interpreter - it raisesSystemExit, 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' $? = 0Expected resultBoth exits caught,
e.codeholding3and then the string - and the script finishing normally with$? = 0.Success conditionYou know
sys.exitis a raise, not a halt. -
Which is why a bare except swallows your exit
except Exception:does not catchSystemExit, because it is not anException. A bareexcept: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 $? = 0Expected result
except Exception:lets the exit through, status 4. The bareexcept:catches it, the script carries on, and the status is 0.Success conditionYou can explain a script that fails and reports success.
-
Cleanup handlers, and the call that skips them
atexithandlers run on a normal exit and onsys.exit.os._exitterminates 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 " \$? = $?" $? = 0Expected result
atexit handler ranforsys.exit; nothing at all foros._exit. Both exit 0.Success conditionYou know which exit runs your cleanup.
-
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 -> 126Expected 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
A script that clearly failed exits 0.
Why: Most likely a bare
except:swallowingSystemExit. Otherwise the script fell off the end without exiting deliberately.Fix:Replace
except:withexcept Exception:. Make every failure path callsys.exitwith a non-zero status.An exit status of 256 or more shows up as 0.
Why: The status is one byte; the low eight bits are all that survive.
Fix:Never use a count as a status.
sys.exit(1 if failures else 0)and put the count in the log.cron mails you nothing about a failing job.
Why: The script exits 0, or produces no output. cron mails on output, and most wrappers act on the status.
Fix:Exit non-zero on failure and log to stderr. See guide 23.
sys.exit("failed")exits 1 when you wanted a specific code.Why: A non-int argument is printed to stderr and the status is always 1.
Fix:Print the message yourself and
sys.exit(3), or accept 1 - which is usually the right answer anyway.The last log line is missing when the script exits.
Why:
os._exit, which skips buffer flushing andatexit.Fix:Use
sys.exit. Reserveos._exitfor a forked child.