Shell Script Strict Mode
Learn how set -euo pipefail, IFS, and traps protect bash scripts from silent failures, undefined variables, and leaked temporary files.
Before you start
- ▸Basic familiarity with writing and running bash scripts
- ▸A text editor and a terminal with bash 4.x or later (check with bash --version)
- ▸shellcheck installed for the verification step (optional but recommended)
Shell scripts fail silently by default. A command returns an error, the script keeps running. An unset variable expands to nothing, corrupting filenames or SQL queries. A pipeline masks the exit code of every stage but the last. These are not edge cases—they bite experienced developers regularly. Enabling strict mode with a handful of set options and a clean trap catches these failures at the point they occur rather than three steps later when the damage is done.
The Core Options: set -euo pipefail
Place this at the top of every non-trivial script, directly after the shebang:
#!/usr/bin/env bash
set -euo pipefail
Each option does one specific thing:
-e— Exit immediately when any command returns a non-zero status (with important caveats covered below).-u— Treat unset variables as an error and exit. Without this,$typosilently expands to an empty string.-o pipefail— Make a pipeline fail if any stage fails, not just the last one. Without this,grep pattern file | wc -lexits 0 even whengrepfinds nothing and returns 1.
Why -e Alone Is Not Enough
Consider this script without -u:
#!/usr/bin/env bash
set -e
DESTINATION="/mnt/backup"
rm -rf "$DESTIANTION/" # typo: undefined var expands to empty string
That rm -rf becomes rm -rf "/". With -u the script would abort with DESTIANTION: unbound variable before any damage occurs.
The pipefail Problem Without the Option
#!/usr/bin/env bash
set -e # no pipefail
# grep returns 1 (no match), but the pipeline exit code is wc's: 0
grep 'ERROR' /var/log/app.log | wc -l
echo "Exit was: $?" # prints 0, script continues happily
Add -o pipefail and the pipeline propagates grep's exit code, so -e can act on it.
Setting IFS for Safer Word Splitting
The Internal Field Separator controls how bash splits strings into words. Its default value is space, tab, and newline—which means filenames or values with spaces get silently split into multiple tokens during iteration.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
Removing the space from IFS means a filename like my project.tar.gz stays as one token when you loop over command output. It does not break properly quoted variables—those are unaffected by IFS—but it prevents accidents with unquoted expansions that you may have missed.
A common pattern you will see in the wild, attributed to Aaron Maxwell's strict mode template, combines all four settings:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
This is a solid baseline for any non-interactive script. If your script genuinely needs to split on spaces (for example, parsing /etc/os-release), reset IFS locally inside a subshell or function rather than globally.
Using trap for Cleanup and Diagnostics
When -e terminates your script early, temporary files, lock files, and mount points can be left behind. A trap on EXIT guarantees your cleanup function runs regardless of how the script exits—normally, via -e, or from a received signal.
Basic Cleanup Trap
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
TMPDIR=$(mktemp -d)
cleanup() {
rm -rf "$TMPDIR"
echo "Cleaned up $TMPDIR" >&2
}
trap cleanup EXIT
# rest of script uses $TMPDIR safely
cp /etc/passwd "$TMPDIR/"
echo "Rows: $(wc -l < "$TMPDIR/passwd")"
The trap cleanup EXIT line registers the function. Even if the cp or any later command fails and -e kills the script, cleanup runs before the process exits.
Diagnostic Trap for Debugging
Add a trap on ERR to print the line number where a failure occurred. This saves significant debugging time in long scripts:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
err_handler() {
echo "Error on line $1" >&2
}
trap 'err_handler $LINENO' ERR
You can combine both traps. Define your cleanup function and your error handler separately, then register them:
trap 'err_handler $LINENO' ERR
trap cleanup EXIT
Both will fire on an error exit: ERR runs first, then EXIT.
Known Caveats and Escape Hatches
Strict mode is not magic; it has well-known rough edges you need to understand.
-e Does Not Trigger Inside Conditionals
A command that is directly tested does not trigger -e even if it fails:
if grep -q 'pattern' file.txt; then
echo "found"
fi
# grep returning 1 (no match) does NOT exit the script here
This is correct and intentional—you are explicitly handling the return value. The same applies to || and && chains, and to commands in while or until conditions.
Suppressing Errors for Expected Failures
Use || true when a command legitimately might fail and you want to continue:
rm -f /tmp/lockfile || true
id someuser || true
Alternatively, disable and re-enable -e around a block, though || true is more surgical:
set +e
some_unreliable_tool
RESULT=$?
set -e
-u and Arrays
In bash, referencing an empty array with ${array[@]} under -u throws an unbound variable error in bash versions before 4.4. The workaround is ${array[@]:-} to provide an empty default, or check the array length first:
args=()
# Safe expansion of potentially empty array:
some_command "${args[@]:-}"
Putting It All Together: A Reusable Template
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# --- Globals ---
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
TMPDIR=$(mktemp -d)
# --- Handlers ---
cleanup() {
rm -rf "$TMPDIR"
}
err_report() {
echo "[ERROR] Script failed at line $1" >&2
}
trap 'err_report $LINENO' ERR
trap cleanup EXIT
# --- Main logic ---
main() {
echo "Working in $TMPDIR"
# your code here
}
main "$@"
Wrapping logic in a main() function keeps variable scope clean and makes the script easier to test. Passing "$@" forwards all positional arguments through.
Verifying Your Script Behaves Correctly
Use bash -n to syntax-check without running:
bash -n myscript.sh
Use shellcheck (available in all major distro repos) for deeper static analysis—it catches unquoted variables, SC2 warnings, and other issues strict mode alone cannot prevent:
# Debian/Ubuntu
sudo apt install shellcheck
# Fedora / RHEL with EPEL
sudo dnf install shellcheck
# Arch
sudo pacman -S shellcheck
shellcheck myscript.sh
Run your script with bash -x to trace every command as it executes—invaluable when debugging why an exit is being triggered:
bash -x myscript.sh
Troubleshooting
- Script exits immediately with no message: An ERR trap that prints the line number will locate the failure. Alternatively run with
bash -xto trace execution. - "unbound variable" on a variable you set: Check for a typo in the variable name. Also verify the variable is not being set inside a subshell (parentheses create a child process; assignments there don't propagate to the parent).
- pipefail breaking a pipeline you rely on: Use an explicit
|| trueon the pipeline, or restructure to capture the return value before piping. - Sourced scripts behaving differently: When you
sourcea script, it runs in the current shell. The calling shell'ssetoptions apply, not the sourced file's. Be explicit about which options each sourced file requires. - POSIX sh compatibility:
pipefailandlocalare bash extensions. If your shebang is#!/bin/sh, some of these options will not exist. Commit to#!/usr/bin/env bashwhen using strict mode.
Frequently asked questions
- Does set -e make my script completely safe from all failures?
- No. Commands inside if conditions, while conditions, and || or && chains do not trigger -e even when they fail, because the shell considers their return value explicitly handled. You still need to understand when exit codes are being tested versus ignored.
- Can I use strict mode with #!/bin/sh instead of bash?
- Partially. The -e and -u options are POSIX and work with sh. The -o pipefail option is a bash extension and will cause an error under dash or other POSIX shells. Commit to #!/usr/bin/env bash when using the full set.
- Why does my script exit when grep finds no matches?
- grep returns exit code 1 when the pattern is not found, which is not an error condition but -e treats any non-zero as fatal. Use grep ... || true, or use an if statement to test grep's return value explicitly.
- Does the EXIT trap run if the script is killed with SIGKILL?
- No. SIGKILL cannot be caught or trapped by any process—the kernel terminates it immediately. EXIT traps fire for normal exits, -e exits, and signals like SIGTERM or SIGINT, but not SIGKILL (kill -9).
- Should I enable strict mode in scripts I source into my shell interactively?
- Be careful. A sourced file runs in your current shell session, so set -e in a sourced file would close your terminal on any command failure. Limit strict mode to non-interactive scripts with a proper shebang, or guard sourced files with conditional checks.
Related guides
Bash Arrays and Associative Arrays
Master bash indexed and associative arrays: declaration, element access, looping, mapfile, namerefs, and practical patterns for real scripting work.
Bash Functions and Variable Scoping
Master Bash function scoping with local variables, source-based libraries, correct use of return codes, and array passing techniques including namerefs.
Bash Loops: for, while and until
Learn all three Bash loop types — for, while, and until — with practical, copy-paste examples covering file iteration, counting, polling, and safe line reading.
Bash Scripting for Beginners
Learn Bash scripting from scratch: shebang lines, variables, conditionals, loops, and arguments, plus a real backup script to tie it all together.