Writing Better Bash Scripts: Tips, Traps, and Patterns
Most Bash scripts start as a handful of commands pasted from a terminal session. They work fine — until they don’t, and debugging the failure is harder than writing the script was. A few habits picked up early make Bash scripts significantly more reliable without making them harder to read. Let’s go through the patterns that matter most.
Start with a Safe Mode Header
Every non-trivial script should start with these options:
#!/usr/bin/env bash
set -euo pipefail
set -e— exit immediately if any command exits with a non-zero statusset -u— treat unset variables as errors (preventsrm -rf $UNDEFINED/)set -o pipefail— a pipeline fails if any command in it fails, not just the last one
Without set -e, a failed git clone won’t stop your deploy script — it’ll blunder onward into broken state.
Quote Everything
Unquoted variables in Bash split on whitespace and expand globs. This causes subtle bugs:
# Bad
path=/home/user/my folder
ls $path # runs: ls /home/user/my folder -> ls two args
# Good
ls "$path" # runs: ls "/home/user/my folder"
The safe rule: always double-quote variable expansions, unless you explicitly want word splitting.
#!/usr/bin/env bash
set -euo pipefail
file="$1"
echo "Processing: $file"
wc -l "$file"
Check If a Variable Is Set
With set -u, accessing an unset variable is a fatal error. To provide a default safely:
name="${NAME:-anonymous}" # use "anonymous" if NAME is unset or empty
port="${PORT:-8080}"
output="${1:-/tmp/output}" # default for positional argument
Use ${VAR:?message} to fail with a meaningful error when a required variable is missing:
DB_HOST="${DB_HOST:?DB_HOST must be set}"
Argument Parsing
For scripts with more than one or two arguments, use getopts:
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 [-v] [-o output] input"
exit 1
}
verbose=false
output=""
while getopts "vo:" opt; do
case "$opt" in
v) verbose=true ;;
o) output="$OPTARG" ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
input="${1:-}"
if [[ -z "$input" ]]; then
echo "Error: input file required" >&2
usage
fi
$ ./myscript.sh -v -o result.txt data.csv
Error Handling and Cleanup
Use a trap to clean up temporary files or undo state when the script exits — even on error:
#!/usr/bin/env bash
set -euo pipefail
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Work in tmpdir — it will be cleaned up automatically
cp important.txt "$tmpdir/"
process "$tmpdir/important.txt"
trap '...' EXIT fires when the script exits for any reason, including set -e failures or Ctrl+C.
Printing Errors to stderr
Error messages should go to stderr, not stdout, so they don’t pollute command substitution:
die() {
echo "Error: $*" >&2
exit 1
}
[[ -f "$config" ]] || die "Config file not found: $config"
Conditional Checks with [[ ]]
Prefer [[ ]] over [ ] — it’s a Bash builtin that handles edge cases more safely:
# String checks
[[ -z "$var" ]] # true if empty
[[ -n "$var" ]] # true if non-empty
[[ "$a" == "$b" ]] # string equality (no quotes needed inside [[ ]])
[[ "$str" == *.log ]] # glob matching
# File checks
[[ -f "$path" ]] # exists and is a regular file
[[ -d "$dir" ]] # exists and is a directory
[[ -x "$bin" ]] # exists and is executable
# Numeric comparison
[[ "$count" -gt 10 ]]
Looping Over Files Safely
Never parse ls. Use globs or find:
# Good: glob expansion
for f in /var/log/*.log; do
[[ -f "$f" ]] || continue # skip if glob matched nothing
echo "Processing $f"
done
# Good: find with null delimiter
find . -name "*.csv" -print0 | while IFS= read -r -d '' file; do
echo "Found: $file"
done
The IFS= read -r -d '' pattern handles filenames with spaces, newlines, and other special characters.
Functions
Bash functions let you avoid repeating code and make logic testable:
#!/usr/bin/env bash
set -euo pipefail
log() {
local level="$1"; shift
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >&2
}
backup_db() {
local db="$1"
local dest="$2"
log INFO "Backing up $db to $dest"
pg_dump "$db" > "$dest"
log INFO "Backup complete"
}
backup_db myapp "/backups/myapp-$(date +%F).sql"
Use local for variables inside functions to avoid polluting the global scope.
Checking for Required Commands
require() {
command -v "$1" &>/dev/null || {
echo "Error: '$1' is required but not installed" >&2
exit 1
}
}
require jq
require curl
require aws
Debugging
Run a script with tracing enabled to print each command before executing it:
$ bash -x myscript.sh
Or enable it for a section of the script:
set -x # start tracing
heavy_operation
set +x # stop tracing
Conclusion
Most Bash script problems trace back to three root causes: unquoted variables, no error handling, and assuming every command succeeds. The set -euo pipefail header, disciplined quoting, and trap for cleanup address all three. Pick these habits up from the start and you’ll spend far less time debugging scripts that “worked in my terminal” but fail in production.