In our previous post about exit code, we learned why exit 0 and exit 1 are the difference between a script that “smiles through a disaster” and one that actually warns you. But manually writing exit 1 after every command quickly becomes tedious, especially as your scripts grow. How do production-grade scripts handle errors without cluttering every line with checks?
The answer lies in three Bash superpowers: the $? variable, standardized exit codes, and the set -euo pipefail safety trio. Let’s break them down with real examples.
By default, Bash is extremely optimistic—it assumes errors are optional, missing variables are fine, and partial success counts as success. While this behavior feels charming in a local terminal, it becomes reckless in automation. Out of the box, Bash will happily execute a command that fails, ignore the failure, and continue as if nothing happened. That’s not resilience; it’s denial. If your script controls anything real, silent failure is the worst possible outcome. The truly dangerous scripts aren’t the ones that crash—they’re the ones that fail halfway, leave systems in weird states, and still exit with code 0. Those scripts pass CI, get promoted, and quietly break production later. Congratulations—you’ve automated uncertainty.
1. The Hidden Power of $?
Every command you run in Bash leaves behind a receipt. That receipt is the $? variable. It holds the exit status of the most recently executed command. The catch? It updates after every command, including echo or variable assignments. If you don’t capture it immediately, it’s overwritten and gone forever.
Watch what happens in this broken example:
#!/bin/bash
ls /nonexistent_dir 2>/dev/null
echo "Exit code was: $?" # ✅ Prints 2 (directory not found)
echo "Just checking..." # ⚠️ This runs successfully
echo "Now $? is: $?" # ❌ Prints 0! The error receipt was replaced.
The Fix: Capture Before It Changes
Always store $? in a named variable the moment you need it:
#!/bin/bash
rsync -av /data/ /backup/ 2>/dev/null
result=$? # ✅ Save the receipt immediately
if [ $result -ne 0 ]; then
echo "Rsync failed with exit code: $result"
exit $result
fi
Pro Tip: You can often skip $? entirely. Bash checks exit codes natively in if statements:
if ! cp critical_config.yml /etc/app/; then
echo "Config copy failed!"
exit 1
fi
The ! negates the success, so the if block only runs when cp returns non-zero. Cleaner, safer, and less prone to $? overwrites.
2. Beyond exit 0 & exit 1: Custom Exit Codes
exit 0 means success. exit 1 means generic failure. But what if your script can fail for five different reasons? Telling a monitoring system or CI/CD pipeline “it failed” isn’t enough. Bash supports exit codes 0–255, and the Linux ecosystem follows established conventions:
0: Success1: General/catchall error2: Misuse of shell builtins (wrong flags/arguments)126: Command found but not executable127: Command not found128+N: Script killed by signalN(e.g.,130= Ctrl+C)
You’re free to define your own codes for application-specific errors. Just pick numbers that won’t collide with signals (usually 10–99 works well):
#!/bin/bash
set -euo pipefail
# Check source directory
if [ ! -d "/var/app/data" ]; then
echo "ERROR: Source directory missing"
exit 10 # Custom: Configuration error
fi
# Check disk space
usage=$(df /var/app/data | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$usage" -gt 90 ]; then
echo "ERROR: Disk usage at ${usage}%. Backup aborted."
exit 20 # Custom: Resource constraint
fi
echo "✅ Backup completed successfully."
exit 0
Now, an automation tool reading the exit code knows exactly what went wrong: 10 means fix the config path, 20 means clear disk space. Always document your custom codes in a header comment so your team (or future you) doesn’t have to guess.
3. The “Fail-Safe” Script: set -euo pipefail
Manually checking every exit code works for tiny scripts. For anything running on a schedule or in production, it’s fragile. Bash provides built-in safety switches that act as an automatic error net. Place these at the top of your script:
#!/bin/bash
set -euo pipefail
Here’s what each flag does:
set -e(errexit): Exits immediately if any command returns non-zero. No more silent failures cascading into data loss.set -u(nounset): Treats unset variables as errors. Prevents typos like$backp_dirfrom accidentally creating empty folders.set -o pipefail: Changes how pipelines behave. By default,cmd1 | cmd2 | cmd3only returns the exit code ofcmd3. Withpipefail, it returns the last non-zero exit code in the chain.
See the difference in action:
# Default Bash behavior
false | echo "Pipe succeeded"
echo $? # Prints 0 (echo's success hid false's failure)
# With set -o pipefail enabled
false | echo "Pipe succeeded"
echo $? # Prints 1 (Bash caught the hidden failure)
What if you expect a command to fail sometimes? You can temporarily disable -e:
set +e # Turn off errexit
ping -c 1 192.168.1.50 || echo "Host unreachable"
set -e # Turn protection back on immediately
🔍 The “Truth Test” for All Three
Run this in your terminal to see how they work together:
bash -c 'set -euo pipefail; false | grep "test"; echo "This never prints"'
echo "Exit: $?"
Result: The script stops at false | grep, returns 1, and the final echo never runs. The computer sees the truth.
📝 Summary
$?is a temporary receipt. Capture it instantly or let Bash handle it inifstatements.- Custom exit codes turn “it broke” into actionable diagnostics for humans and machines.
set -euo pipefailautomates error catching, so you don’t have to manually guard every line.
Start adding these to your scripts today. Your backups, deployments, and sanity will thank you.
Leave a Reply