GICT knowledge base
The Exit Code Explained: How Scripts Report Success and Failure
Published
Every command you run on Linux ends with a number. You rarely see it, but it is always there — the exit code. It tells the shell whether the command succeeded, failed, or was killed by a signal. Understanding exit codes is what separates a script that silently ignores failures from one that handles errors correctly.
What an exit code actually is
When a process finishes, it exits and passes a number to the kernel. This is the exit status. The kernel stores it in the process table so the parent process can read it. The shell collects it and stores it in the special variable $?.
The convention is simple: zero means success. Anything non-zero means failure. The specific number tells you what kind of failure — but that part is convention, not enforcement. A program can exit with any number it wants. The shell just reports what it was given.
You can see the exit code of the last command immediately after running it:
ls /etc/passwd
echo $?
That prints 0 — success. Now try a file that does not exist:
ls /nonexistent
echo $?
That prints 2. The ls command uses 2 to mean “file not found.” Most commands follow a loose convention where 1 means a general error and 2 means a usage or input error, but it varies by program.
The 0-255 range and what it means
Exit codes are 8-bit values — they range from 0 to 255. This matters because the kernel truncates larger numbers. If a program exits with code 300, the shell receives 44 (300 modulo 256).
There are three important ranges beyond 0:
- 1 — general error. Most programs use this when something goes wrong but there is no more specific code.
- 126 — the command was found but could not be executed. This usually means the file does not have execute permission or is not a valid executable format.
- 127 — the command was not found. The shell could not locate the executable in PATH.
- 128+N — the process was killed by signal N. If a process is killed with SIGTERM (signal 15), it exits with code 143 (128 + 15). SIGKILL (signal 9) gives 137 (128 + 9). This range only appears when a process does not exit voluntarily — it is terminated by the kernel or another process.
Values above 128 almost always mean the process was killed, not that it returned an error. If your script exits with code 137, something sent it SIGKILL.
Capturing exit codes
$? holds the exit code of the most recent foreground command. It is overwritten immediately by the next command, so capture it right away:
grep "root" /etc/passwd
if [ $? -eq 0 ]; then
echo "Found root user"
fi
Or on one line, using the exit code directly in a conditional:
if grep "root" /etc/passwd; then
echo "Found root user"
fi
The if statement runs the command and checks its exit code directly — no need for $?. This is the cleaner pattern.
Pipes and exit codes
Here is where most scripts go wrong. When you pipe commands, $? only reflects the rightmost command:
grep "something" /etc/passwd | wc -l
echo $?
That prints the exit code of wc, not grep. If grep found nothing, it exits with 1, but wc exits with 0 because it succeeded in counting zero lines. Your error check is checking the wrong command.
The shell provides PIPESTATUS to solve this. It is an array holding the exit codes of every command in the last pipeline:
grep "something" /etc/passwd | wc -l
echo "grep: ${PIPESTATUS[0]}, wc: ${PIPESTATUS[1]}"
With set -o pipefail, the pipeline exit code becomes the rightmost non-zero exit code, or zero if all succeed. Without it (the default), a pipeline returns the exit code of the last command regardless of what failed earlier.
set -e and set -o pipefail
By default, bash continues running a script even when a command fails. This is a design choice that often produces scripts that run to completion while ignoring errors along the way:
#!/bin/bash
mkdir /opt/myapp
cd /opt/myapp
tar -xzf /tmp/source.tar.gz
# If tar fails, cd already succeeded, so we stay in /opt/myapp
# but now the next command operates on the wrong directory
set -e makes the script exit immediately when any command returns a non-zero exit code:
#!/bin/bash
set -e
mkdir /opt/myapp
cd /opt/myapp
tar -xzf /tmp/source.tar.gz
If tar fails, the script stops there. No more commands run in the wrong directory.
There are exceptions. set -e does not trigger on the condition of a while loop, or when a command is part of a chain using && or ||. The full rules are in the bash manual, but the practical effect is that some failure cases slip through.
set -o pipefail does not change when a script exits, but it changes what exit code a pipeline produces. Without it, grep pattern file | cut -d: -f1 returns 0 even if grep found nothing because cut succeeded. With it, the pipeline returns the grep exit code if grep failed. Use both together:
#!/bin/bash
set -eo pipefail
Signals and exit codes
When a process is terminated by a signal, its exit code is 128 plus the signal number. This lets you distinguish between a voluntary exit and a kill:
#!/bin/bash
sleep 30 &
PID=$!
kill -TERM $PID
wait $PID 2>/dev/null
echo "Exit code: $?"
That prints 143 (128 + 15, where 15 is SIGTERM). Now try SIGKILL:
#!/bin/bash
sleep 30 &
PID=$!
kill -KILL $PID
wait $PID 2>/dev/null
echo "Exit code: $?"
That prints 137 (128 + 9). SIGKILL cannot be caught or handled, so the kernel terminates the process directly and reports 137.
SIGTERM (15) is the default signal sent by kill. A process can intercept it, clean up, and exit gracefully. SIGKILL (9) cannot be intercepted — the kernel terminates the process immediately and skips any cleanup handlers.
Propagating errors properly in scripts
The cleanest pattern is to check exit codes explicitly and use set -e as a safety net:
#!/bin/bash
set -eo pipefail
# Stop on any error
curl -fsS https://api.example.com/data -o /tmp/data.json || { echo "Download failed"; exit 1; }
process_data /tmp/data.json || { echo "Processing failed"; exit 1; }
rm /tmp/data.json
The || { } pattern runs both commands in the block. If the left side fails, the block runs and the script exits with 1.
You can also wrap this in a function for cleaner scripts:
fail() {
echo "Error: $1" >&2
exit 1
}
curl -fsS https://api.example.com/data -o /tmp/data.json \
|| fail "Download failed"
process_data /tmp/data.json \
|| fail "Processing failed"
Writing errors to stderr (&2) ensures they do not get swallowed if you redirect the script output.
Common exit code conventions
Most programs follow a loose set of conventions. Knowing them helps you interpret what went wrong without reading the source:
0 -- success
1 -- general error
2 -- misuse or bad input (used by ls, cp, mv, grep)
126 -- found but not executable
127 -- command not found in PATH
128+N -- killed by signal N (137 = SIGKILL, 143 = SIGTERM)
130 -- script interrupted by Ctrl+C (SIGINT, 128+2)
If your script exits with 130 and you did not write code to produce that, someone pressed Ctrl+C while it was running.
set -eo pipefail at the top of every script is the lowest-effort way to enforce this — the shell stops you before a cascading failure gets out of hand.Related guides
Plan a Safe Redis-to-Valkey Migration
Plan a Redis-to-Valkey migration: check version compatibility, rehearse recovery, stop writers for cutover and protect against lost writes during rollback.
Linux AdministrationHow to Restrict an SSH Key to One Command
Restrict an SSH key to one command with authorized_keys options. Protect the command and check that unwanted shell and forwarding access are denied.
Linux AdministrationHow to Schedule Tasks with systemd Timers
Schedule tasks with systemd timers. Verify service results separately from timer status, test missed-run handling and use an explicit UTC schedule.
Linux AdministrationHow to Restrict a Linux Service with systemd Security Settings
Restrict a Linux service with systemd drop-ins, a dedicated account, read-only paths, private temporary storage, and controlled writable directories.
