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 statusn(0-255). Omittingnreuses the status of the last command that ran.return [n]— inside a function, ends the function with statusninstead 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:
- Bash runs
tar -czf "$archive" "$backup_dir". Thetarprocess runs to completion, then callsexit()with its own status:0if the archive was created successfully, non-zero if, say,backup_dirdoesn’t exist or a file inside it couldn’t be read. - The kernel stores that status and hands it to Bash (the parent process) via
waitpid(). Bash immediately places it in$?. - The very next line,
status=$?, is itself a command (a variable assignment) — but it runs before anything else can touch$?, so it correctly capturestar‘s status into thestatusvariable. From this point on$?is free to change, but the value is now safely preserved in$status. - The
[[ $status -eq 0 ]]test compares the saved value numerically. Because it’s a plain variable read, not$?, running other commands beforehand (like theifitself) never disturbs it. - Depending on the result, the script either prints a success message, or prints a failure message to stderr (
>&2) and callsexit "$status", propagatingtar‘s original exit code as the script’s own — so a caller checking$?after running this script sees exactly whattarreported.
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/whilecondition (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 pipefailin 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.
1for a missing argument,2for a missing file) so callers — including cron and CI systems — can react programmatically instead of just knowing ‘something failed’. - Stay within
1-125for your own custom exit codes;126,127, and128+Nare effectively reserved by the shell and kernel for the meanings described above. - Remember
chmod +xbefore running a script directly with./script.sh— otherwise you’ll see exit code126and possibly misdiagnose it as a bug in the script.
Practice Exercises
- Write a script called
ping_check.shthat runsping -c 1against a host given as$1, then prints ‘Host is up’ or ‘Host is unreachable’ depending on the exit code. (Hint: you can put thepingcommand directly in theifcondition.) - Write a script that runs two commands in sequence — for example, a
mkdirfollowed by acpinto the new directory — and exits with status1if either command fails, printing which one failed. Capture each exit code into its own variable so you can tell them apart. - Run
grep foo /etc/hostname,grep root /etc/passwd, andgrep foo /no/such/fileone at a time, checking$?after each. Note the three different exit codes you get and explain, in your own words, what each one means forgrepspecifically.
Summary
- Every command reports an exit status (0-255) to its parent when it finishes; by convention,
0means 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.126means ‘found but not executable’ (often a missingchmod +x),127means ‘command not found’, and128+Nmeans ‘killed by signal N’.- Use
exit nto set a script’s own exit status, andreturn nto set a function’s. - A pipeline’s default exit status is only its last command’s — use
set -o pipefailwhen an earlier stage’s failure should still count as failure. - Prefer putting commands directly in
if/whileconditions over manually checking$?when you only need a yes/no result.
