Writing Robust Bash Scripts
September 21, 2026
Bash scripts have a reputation for being fragile. A missing variable silently expands to nothing, a failing command gets ignored, and your script marches on doing damage. The fix is straightforward: a few lines at the top of your script and some discipline around error handling.
The Strict Mode Header
Start every script with this.
#!/bin/bash
set -euo pipefail
That single line enables three options that catch the most common scripting bugs.
set -e: Exit on Error
Without -e, bash continues running after a command fails. With it, any command that returns a non-zero exit code terminates the script immediately.
set -e
cp /tmp/important-data /backup/ # if this fails...
echo "Backup complete" # this never runs
There are exceptions. Commands in if conditions, while loops, and those followed by && or || are allowed to fail without triggering an exit.
set -e
# These are fine - failure is handled explicitly
if grep -q "pattern" file.txt; then
echo "found"
fi
command_that_might_fail || echo "it failed, but we're okay"
set -u: Error on Undefined Variables
Without -u, referencing an unset variable silently expands to an empty string. With it, bash throws an error and stops.
set -u
echo "$UNDEFINED_VAR" # error: UNDEFINED_VAR: unbound variable
This catches typos and missing environment variables before they cause problems. If you intentionally want to use a variable that may be unset, provide a default.
echo "${OPTIONAL_VAR:-default_value}"
echo "${OPTIONAL_VAR:=assigned_if_empty}"
set -o pipefail: Catch Pipe Failures
By default, a pipeline's exit code is the exit code of the last command. This hides failures in earlier commands.
# Without pipefail: exit code is 0 (wc succeeds)
cat /nonexistent/file | wc -l
# With pipefail: exit code is 1 (cat fails)
set -o pipefail
cat /nonexistent/file | wc -l
Combined with set -e, this means a failure anywhere in a pipeline stops the script.
Trap: Cleanup and Error Handling
The trap builtin lets you run a function when the script exits, whether it finishes normally, fails, or gets interrupted.
Cleanup on Exit
This is the most common pattern. Create temporary files, and make sure they get cleaned up no matter what happens.
#!/bin/bash
set -euo pipefail
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Use tmpdir freely - it will be cleaned up on exit
curl -so "$tmpdir/data.json" https://api.example.com/data
process_data "$tmpdir/data.json"
The EXIT trap fires when the script ends for any reason: normal exit, set -e triggered, Ctrl+C, or kill.
Error Reporting
For longer scripts, knowing where a failure happened is valuable. Trap ERR to log the line number.
#!/bin/bash
set -euo pipefail
trap 'echo "Error on line $LINENO. Exit code: $?" >&2' ERR
# Now any failure prints its location
Combining Traps
You can combine cleanup and error reporting.
#!/bin/bash
set -euo pipefail
tmpdir=$(mktemp -d)
cleanup() {
local exit_code=$?
rm -rf "$tmpdir"
if [[ $exit_code -ne 0 ]]; then
echo "Script failed with exit code $exit_code" >&2
fi
exit "$exit_code"
}
trap cleanup EXIT
Handling Signals
You can also trap specific signals to handle interrupts gracefully.
#!/bin/bash
set -euo pipefail
pid=""
shutdown() {
echo "Caught signal, shutting down..."
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
fi
exit 0
}
trap shutdown SIGTERM SIGINT
# Start background process
long_running_command &
pid=$!
wait "$pid"
Input Validation
Validate arguments and inputs early. Fail fast with a clear message.
#!/bin/bash
set -euo pipefail
usage() {
echo "Usage: $0 <source_dir> <dest_dir>" >&2
exit 1
}
[[ $# -ne 2 ]] && usage
src_dir="$1"
dest_dir="$2"
[[ -d "$src_dir" ]] || { echo "Error: $src_dir is not a directory" >&2; exit 1; }
[[ -d "$dest_dir" ]] || { echo "Error: $dest_dir is not a directory" >&2; exit 1; }
Quoting Everything
Unquoted variables are the source of countless bash bugs. Always quote variable expansions.
# Wrong - breaks on filenames with spaces
for f in $(ls *.txt); do
cp $f /backup/
done
# Right - handles spaces and special characters
for f in *.txt; do
cp "$f" /backup/
done
The rule is simple: if you are not specifically trying to split a value into words, put double quotes around it.
Readonly Variables
If a variable should not change after being set, mark it as readonly. This prevents accidental reassignment.
readonly CONFIG_DIR="/etc/myapp"
readonly LOG_FILE="/var/log/myapp.log"
readonly MAX_RETRIES=3
Logging
For scripts that run unattended, add structured logging.
#!/bin/bash
set -euo pipefail
readonly LOG_FILE="/var/log/backup.log"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" | tee -a "$LOG_FILE"
}
log "INFO" "Backup starting"
# ... do work ...
log "INFO" "Backup complete"
A Complete Template
Here is a template that brings all of these patterns together. Use it as a starting point for your scripts.
#!/bin/bash
set -euo pipefail
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Cleanup
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
trap 'echo "${SCRIPT_NAME}: Error on line $LINENO" >&2' ERR
# Logging
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" >&2; }
# Argument check
if [[ $# -lt 1 ]]; then
echo "Usage: ${SCRIPT_NAME} <argument>" >&2
exit 1
fi
readonly TARGET="$1"
log "INFO" "Starting with target: ${TARGET}"
# Your script logic here
log "INFO" "Done"
These patterns are not complicated, but they make a dramatic difference. A script with set -euo pipefail and a cleanup trap is far easier to debug when something goes wrong and far less likely to silently corrupt your data. Get in the habit of including them from the start and you will save yourself hours of troubleshooting.