Handle Command Failures
Problem statement
Run a three-step deploy where the second step fails, report which step failed and its exit code, and stop with that code. Along the way, see how exit codes work in pipelines, what PIPESTATUS shows, and two places where set -e does not stop a script. A script that hides a failure is worse than no script, because everyone trusts its "done".
The script uses three fake steps:
steps
fetch succeedsbuild fails with exit code 3deploy would succeed, but must not run after a failed build- Show the exit code of a pipeline with and without
pipefail, and every code withPIPESTATUS. - Show the codes for "command not found" and "not executable".
- Show that
local out=$(false)hides the failure, and how to catch it. - Run the steps, report the failure, skip
deploy, and print the exit code the script would end with.
Expected output:
== pipelines ==without pipefail: exit 0with pipefail: exit 1PIPESTATUS: 1 0 2== common codes ==command not found: 127not executable: 126== local hides the failure ==local out=$(false): exit 0local out; out=$(false): exit 1== deploy ==step fetch fetching source okstep build compiling FAILED: build exited with 3stopped early; the script would end with: exit 3Hints
$? is the exit code of the last command. In a pipeline, it is the last command's code unless set -o pipefail is on. PIPESTATUS is an array with every command's code.Approach
Optimal: Exit codes, PIPESTATUS and run_step
Covers: exit codes, $?, if cmd, cmd || handle, pipelines and pipefail, PIPESTATUS, codes 1, 2, 126, 127, 130, 137, where set -e does not stop, local x=$(cmd).
Strict mode, used on every page in this section. The second line, set -euo pipefail, makes bash stop on mistakes instead of carrying on:
| Option | Means |
|---|---|
-e |
exit as soon as a command fails (with some exceptions, see Handle Command Failures) |
-u |
treat an unset variable as an error, instead of silently using empty text |
-o pipefail |
a pipeline fails if any command in it fails, not just the last one |
Put it right after the shebang line #!/usr/bin/env bash in every script you write.
Every command returns a number. 0 means success; anything from 1 to 255 means some kind of failure. $? holds the code of the last command. Common ones:
| Code | Usually means |
|---|---|
| 0 | success |
| 1 | general failure |
| 2 | wrong usage, bad arguments |
| 126 | found, but not executable |
| 127 | command not found |
| 130 | stopped with Ctrl+C (128 + 2) |
| 137 | killed with KILL (128 + 9) |
Pipelines hide failures by default. A pipeline's code is the code of its last command. So false | true "succeeds", and a curl | tar where curl failed looks fine. set -o pipefail makes the pipeline fail if any part fails. To see every part, read PIPESTATUS right after the pipeline, before any other command overwrites it:
1"]:::red ~~~ P2["true
0"]:::green ~~~ P3["exit 2
2"]:::red end P --> S["PIPESTATUS = 1 0 2"]:::yellow classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style P fill:transparent,stroke:#7c3aed,stroke-width:2px
Where set -e does not stop the script. set -e is helpful, but it is switched off in a few places, and people rely on it there by mistake:
- in a condition:
if cmd,while cmd, and on the left of&&or||. Inside a function called that way, too. - in
local out=$(cmd): the code you see is the code oflocal, which is always 0. Declare first, then assign:local out; out=$(cmd).
So for important steps, check the code yourself instead of trusting set -e.
A step runner. run_step name command... runs the command inside if. On success it prints ok; on failure it captures $?, prints the step name and code, and returns that code. run_all chains the steps with || return, so the first failure stops the rest and passes its code upward.
ok"]:::green --> B["build
exit 3"]:::red B --> D["deploy
skipped"]:::gray B --> E(["script exits with 3"]):::red classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
Walking through the code. The # Setup: lines only define the three fake steps, so skip past them.
set +o pipefailin a subshell shows the default; then the same pipeline withpipefailfails.set +earound thePIPESTATUSline lets the failing pipeline finish so its codes can be printed.no-such-commandgives 127; a file without the run permission gives 126.local out=$(false)reports 0; the split form reports the real 1.run_all || code=$?runs the chain.buildfails with 3,deploynever runs, and the script would end withexit "$code".
Edge cases. A function's exit code is that of its last command unless it uses return N. Exit codes above 255 wrap around (exit 256 is 0), so keep them small. ! in front of a pipeline flips its result, and set -e ignores it too.
#!/usr/bin/env bash
set -euo pipefail
# Setup: three fake deploy steps in a fresh temporary folder
cd "$(mktemp -d)"
step_fetch() { echo " fetching source"; }
step_build() { echo " compiling"; return 3; }
step_deploy() { echo " deploying"; }
echo "== pipelines =="
( set +o pipefail; false | true; echo "without pipefail: exit $?" )
false | true || echo "with pipefail: exit $?"
set +e
false | true | (exit 2)
codes=("${PIPESTATUS[@]}")
set -e
echo "PIPESTATUS: ${codes[*]}"
echo "== common codes =="
no-such-command 2>/dev/null || echo "command not found: $?"
echo 'echo hi' > notexec.sh
./notexec.sh 2>/dev/null || echo "not executable: $?"
echo "== local hides the failure =="
f() { local out=$(false); echo "local out=\$(false): exit $?"; }
f
g() { local out; out=$(false) || echo "local out; out=\$(false): exit $?"; }
g
run_step() {
local name=$1; shift
echo "step $name"
if "$@"; then
echo " ok"
else
local code=$?
echo " FAILED: $name exited with $code"
return "$code"
fi
}
run_all() {
run_step fetch step_fetch || return
run_step build step_build || return
run_step deploy step_deploy || return
}
echo "== deploy =="
code=0
run_all || code=$?
echo "stopped early; the script would end with: exit $code"Interview follow-ups
Always print a summary at the end, even when a step fails and the script stops.
Record each step's result in a variable or array as it runs, and print them from an
EXITtrap:trap 'echo "summary: ${results[*]:-none}"' EXIT. The trap runs whether the script ends normally, withexit 3, or becauseset -estopped it. The next pages showtrapin detail, including cleaning up temp files the same way.
Frequently asked questions
Yes, as a safety net: it catches the many small commands you would never check by hand. But do not rely on it for the important steps, because of the exceptions on this page: conditions, && and || chains, functions called from them, and local x=$(cmd). For those, check the code explicitly with if or || { handle; }. Many teams use both, plus shellcheck, which warns about several of these traps.
Add || true to the command: rm -f old.tmp || true or grep -c error app.log || true. That tells readers you expected it to fail sometimes and it is fine. For grep, remember that exit code 1 means "no match" and 2 means a real error, so grep ... || [[ $? -eq 1 ]] ignores only the harmless case. Never wrap a whole script in set +e to get past a failure.
(( n++ )) returns the old value, and (( 0 )) counts as false, which is exit code 1, so set -e stops the script. It is a well-known surprise. Write n=$((n + 1)) or (( n += 1 )) instead, which never evaluate to 0 when counting up from 0. The same applies to let.