Exit Codes and $?

Every command you run in Bash — whether it’s ls, a shell script, or a compiled program — reports back a small number called an exit code (or exit status) when it finishes. This number tells the shell, and any script that called the command, whether it succeeded or failed, and often hints at how it failed. Bash stores the exit code of the most recently finished foreground command in the special variable $?, and checking it — directly or through if, &&, or || — is how real scripts detect and react to failure instead of blindly continuing after something breaks.

Overview: How Exit Codes Work

In Linux, every process — a running instance of a program — terminates by calling the exit() system call (directly, or implicitly when its main function returns), handing the kernel a single integer as it does: the exit status. The kernel stores this value and makes it available to the process’s parent through the wait() or waitpid() system calls. When that parent is your interactive Bash shell, or a Bash script that launched the command, Bash retrieves the value and stores it in the special parameter $?. Because the kernel only reserves 8 bits for this number, valid exit statuses run from 0 to 255; if a program tries to exit with a larger value, such as exit(256), it wraps around modulo 256 and Bash reports 0.

By long-standing Unix convention — not something the kernel enforces — an exit status of 0 means the command succeeded, and any non-zero value means it failed in some way. The specific non-zero number is left up to the program: grep uses 1 to mean ‘no match found’ and 2 for a real error like a missing file, diff uses 1 to mean ‘the files differ’, and many simpler programs just use 1 for any failure. There is no universal meaning beyond ‘zero is good, non-zero is bad’ — you have to know, or look up, what a specific command’s non-zero codes mean if you want your script to react differently to different kinds of failure.

$? always refers to the exit status of the most recently completed foreground command, and that includes almost anything you run — which makes it a moving target. If you run a command, then run echo or any other command, and only then check $?, you are reading the exit status of that in-between command, not the one you actually meant to check. This is one of the most common sources of bugs in Bash scripts (see Common Mistakes below); the fix is to capture $? into a variable immediately after the command whose result matters.

A handful of exit codes carry conventional special meaning that Bash itself assigns on top of a program’s own logic. 126 means the shell found the file you tried to run but could not execute it — usually because it lacks the execute permission bit, or its first line does not point to a valid interpreter. 127 means the shell could not find the command at all, typically a typo or a missing entry in $PATH. Codes from 128 upward indicate the process was killed by a signal: Bash reports 128 + N, where N is the signal number, so 130 (128+2) means the process was terminated by SIGINT (Ctrl+C), and 137 (128+9) means it was killed by SIGKILL. Recognizing these patterns lets you diagnose failures at a glance instead of guessing.

Inside a script, you control the exit status explicitly with the exit builtin: exit 0 for success, exit 1 (or any other non-zero number you choose) for failure. If a script finishes without ever calling exit, its exit status is simply the exit status of the last command it ran. The same idea applies to Bash functions: a function’s status is either whatever you pass to return n, or, if you never call return, the exit status of the last command executed inside the function.

Syntax

There is no special syntax to ‘get’ an exit code — every command sets one as a side effect of finishing, and $? simply reads it back immediately afterward:

some_command --with-args
echo "$?"

Use the following forms to set or branch on exit status in your own scripts:

  • $? — expands to the exit status of the last foreground command, pipeline, or function call. Read it before running anything else.
  • exit [n] — terminates the current shell or script immediately with status n (0-255). Omitting n reuses the status of the last command that ran.
  • return [n] — inside a function, ends the function with status n instead of ending the whole script.
Exit Code Meaning
0 Success
1-125 Failure; the specific meaning is defined by the program itself
126 Command found but not executable (missing chmod +x, or a bad interpreter line)
127 Command not found (typo, or not in $PATH)
128+N Terminated by signal N — e.g. 130 = SIGINT (Ctrl+C), 137 = SIGKILL, 143 = SIGTERM

Examples

Example 1: Checking a Simple Command’s Exit Status

ls /etc/hostname
echo "Exit code: $?"

Output:

/etc/hostname
Exit code: 0

/etc/hostname exists, so ls succeeds and exits with 0. Now try a file that does not exist:

ls /etc/not-a-real-file
echo "Exit code: $?"

Output:

ls: cannot access '/etc/not-a-real-file': No such file or directory
Exit code: 2

ls prints an error to stderr and exits with 2, GNU coreutils’ convention for ‘a serious error occurred’. Either way, $? reflects exactly what happened — you don’t have to parse the printed message to know whether the command worked.

Example 2: Branching on Exit Status in a Script

Scripts rarely need to look at $? directly — you can usually put the command straight into the if condition, since if evaluates any command’s exit status for you:

#!/usr/bin/env bash
set -uo pipefail

log_file="/var/log/app.log"

if grep -q "ERROR" "$log_file"; then
    echo "Errors found in $log_file"
    exit 1
else
    echo "No errors found"
    exit 0
fi

Output (when the log contains at least one line with ‘ERROR’):

Errors found in /var/log/app.log

The -q flag tells grep to search silently and just set its exit status: 0 if it found a match, 1 if it did not. The script’s own exit 1 and exit 0 then let whatever ran this script — a cron job, a CI pipeline, another script — check its $? and react accordingly.

Example 3: Capturing $? for Later Use

Sometimes you need the exit code itself, not just a yes/no branch — for example, to log it or to pass it along as your own script’s exit status:

#!/usr/bin/env bash
set -uo pipefail

backup_dir="/home/ana/projects"
archive="/home/ana/backups/projects-$(date +%F).tar.gz"

tar -czf "$archive" "$backup_dir"
status=$?

if [[ $status -eq 0 ]]; then
    echo "Backup succeeded: $archive"
else
    echo "Backup failed with exit code $status" >&2
    exit "$status"
fi

Output (on success):

Backup succeeded: /home/ana/backups/projects-2026-08-04.tar.gz

Here $? is saved into status immediately after tar finishes, before any other command can overwrite it. The script then uses that saved value both to decide what to print and, on failure, as its own exit code via exit "$status" — so a caller running this script sees the same exit status tar produced.

How It Works Step by Step

Walking through the backup script from Example 3 shows exactly when $? changes and why the order of operations matters:

  1. Bash runs tar -czf "$archive" "$backup_dir". The tar process runs to completion, then calls exit() with its own status: 0 if the archive was created successfully, non-zero if, say, backup_dir doesn’t exist or a file inside it couldn’t be read.
  2. The kernel stores that status and hands it to Bash (the parent process) via waitpid(). Bash immediately places it in $?.
  3. The very next line, status=$?, is itself a command (a variable assignment) — but it runs before anything else can touch $?, so it correctly captures tar‘s status into the status variable. From this point on $? is free to change, but the value is now safely preserved in $status.
  4. The [[ $status -eq 0 ]] test compares the saved value numerically. Because it’s a plain variable read, not $?, running other commands beforehand (like the if itself) never disturbs it.
  5. Depending on the result, the script either prints a success message, or prints a failure message to stderr (>&2) and calls exit "$status", propagating tar‘s original exit code as the script’s own — so a caller checking $? after running this script sees exactly what tar reported.

Common Mistakes

Mistake 1: Letting Another Command Overwrite $? Before You Check It

grep "ERROR" /var/log/app.log
echo "Checking result..."
if [[ $? -eq 0 ]]; then
    echo "Found errors"
fi

This looks reasonable, but echo "Checking result..." runs between grep and the if, and echo almost always succeeds — so $? is now echo‘s exit status (0), not grep‘s. The if will report ‘Found errors’ even when grep found nothing. Save the value right away instead:

grep "ERROR" /var/log/app.log
status=$?
echo "Checking result..."
if [[ $status -eq 0 ]]; then
    echo "Found errors"
fi

Mistake 2: Trusting $? After a Pipeline

cat /var/log/app.log | grep "ERROR" | sort
echo "$?"

By default, a pipeline’s exit status is the exit status of only its last command — here, sort. If cat fails because the log file doesn’t exist, grep and sort still run (on empty input), sort still succeeds, and $? reports 0 even though the pipeline effectively did nothing useful. Turn on pipefail so the pipeline’s status becomes the last non-zero status among all its stages, or 0 if every stage succeeded:

set -o pipefail
cat /var/log/app.log | grep "ERROR" | sort
echo "$?"

Mistake 3: Forgetting to Make a Script Executable

./deploy.sh
echo "$?"

Output:

bash: ./deploy.sh: Permission denied
126

Exit code 126 here doesn’t mean anything went wrong inside deploy.sh — the script never even started running. The file is missing its execute permission bit. Fix it with chmod +x and run it again:

chmod +x deploy.sh
./deploy.sh

Best Practices

  • When you only need a yes/no decision, put the command directly in the if/while condition (if grep -q ...; then) instead of running it and separately checking $? — it’s shorter and avoids the overwrite bug entirely.
  • When you need the exact numeric code later, capture it into a variable (status=$?) on the very next line, before any other command runs.
  • Add set -o pipefail in scripts where a pipeline’s overall success matters, so a failure early in the pipe isn’t hidden by a later stage succeeding.
  • Give your own scripts meaningful, documented exit codes (e.g. 1 for a missing argument, 2 for a missing file) so callers — including cron and CI systems — can react programmatically instead of just knowing ‘something failed’.
  • Stay within 1-125 for your own custom exit codes; 126, 127, and 128+N are effectively reserved by the shell and kernel for the meanings described above.
  • Remember chmod +x before running a script directly with ./script.sh — otherwise you’ll see exit code 126 and possibly misdiagnose it as a bug in the script.

Practice Exercises

  1. Write a script called ping_check.sh that runs ping -c 1 against a host given as $1, then prints ‘Host is up’ or ‘Host is unreachable’ depending on the exit code. (Hint: you can put the ping command directly in the if condition.)
  2. Write a script that runs two commands in sequence — for example, a mkdir followed by a cp into the new directory — and exits with status 1 if either command fails, printing which one failed. Capture each exit code into its own variable so you can tell them apart.
  3. Run grep foo /etc/hostname, grep root /etc/passwd, and grep foo /no/such/file one at a time, checking $? after each. Note the three different exit codes you get and explain, in your own words, what each one means for grep specifically.

Summary

  • Every command reports an exit status (0-255) to its parent when it finishes; by convention, 0 means success and non-zero means failure.
  • $? holds the exit status of the most recently finished foreground command — check it, or save it to a variable, immediately, since the next command you run overwrites it.
  • 126 means ‘found but not executable’ (often a missing chmod +x), 127 means ‘command not found’, and 128+N means ‘killed by signal N’.
  • Use exit n to set a script’s own exit status, and return n to set a function’s.
  • A pipeline’s default exit status is only its last command’s — use set -o pipefail when an earlier stage’s failure should still count as failure.
  • Prefer putting commands directly in if/while conditions over manually checking $? when you only need a yes/no result.