Skip to content

Writing Robust Bash Scripts

Most "it works on my machine" shell scripts fail the moment they hit an unexpected input, an empty variable, or a command that fails silently. This article covers the practices that make the difference between a script that fails loudly and safely, and one that corrupts data at 3am.

How it works

Bash executes a script line by line, by default ignoring the exit status of every command except the last one in a pipeline, and treating unset variables as empty strings rather than errors. Both defaults are wrong for almost any script you'll write in production.

set -euo pipefail

#!/usr/bin/env bash
set -euo pipefail
  • set -e — exit immediately if any command returns a non-zero exit status (with some exceptions: commands in an if condition, or followed by &&/||, are exempt, since their failure is explicitly being checked).
  • set -u — treat referencing an unset variable as an error, instead of silently substituting an empty string. This catches typos like $FIEL instead of $FILE immediately.
  • set -o pipefail — in a pipeline (cmd1 | cmd2), the overall exit status becomes the last (rightmost) non-zero status among all stages, instead of only the last command's status. Without this, grep pattern missingfile.txt | sort can fail silently — grep's error is swallowed because sort (which never sees any error) is what determines the pipeline's reported exit status.

This combination is sometimes called "bash strict mode." It is not a silver bullet — a command you expect might fail (like grep finding no matches) needs explicit handling, or the whole script exits:

if grep -q "pattern" file.txt; then
  echo "found"
else
  echo "not found"
fi

Functions and local variables

myfunc() {
  local name="$1"
  echo "Hello, ${name}!"
}
myfunc "World"

Output:

Hello, World!

local matters: without it, name would be a global variable, visible to (and overwritable by) every other function in the script — a common source of bugs in scripts that grew past a few dozen lines.

Argument parsing

A while/case loop is the standard, dependency-free way to parse flags and options:

#!/usr/bin/env bash
set -euo pipefail
verbose=0
while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose) verbose=1; shift ;;
    -f|--file) file="$2"; shift 2 ;;
    *) echo "unknown arg: $1" >&2; exit 1 ;;
  esac
done
echo "verbose=$verbose file=${file:-none}"

Run as ./script.sh -v -f /etc/hosts:

verbose=1 file=/etc/hosts

Note ${file:-none} — this is a default-value expansion: if file was never set (the -f flag wasn't passed), it evaluates to the literal string none instead of erroring under set -u.

Ubuntu/Debian vs. RHEL-family differences

Bash itself behaves identically across distributions — the differences are in what /bin/sh points to. On Debian/Ubuntu, /bin/sh is dash, a POSIX-minimal shell that does not support bash-isms like [[ ]], arrays, or process substitution. On RHEL-family systems, /bin/sh is usually bash itself (running in a restricted POSIX-compatible mode). Always shebang your scripts #!/usr/bin/env bash, not #!/bin/sh, unless you've deliberately written POSIX-only sh syntax and tested it with dash specifically.

Practical exercise

  1. Write a script backup-check.sh that takes a directory path as its only argument, uses set -euo pipefail, and exits with an error message (to stderr) and a non-zero exit code if the directory doesn't exist.
  2. Add a local function that counts the number of files in that directory using find piped to wc -l, and prints the count.
  3. Deliberately break pipefail protection by piping a command that doesn't exist into wc -l, observe the (wrong) exit code without pipefail, then add set -o pipefail back and observe the corrected exit code.

How to verify you did it correctly

  • ./backup-check.sh /nonexistent; echo $? should print a No such directory style message to stderr and a final exit code of 1 (or whatever non-zero code you chose), not 0.
  • nonexistentcmd | wc -l; echo $? without pipefail prints 0 (wrong — wc -l succeeded even though nonexistentcmd failed); with set -o pipefail, it prints 127 (or 1, the actual failing stage's status).

Common mistakes

  • Unquoted variable expansions: rm -rf $DIR/* instead of rm -rf "${DIR}"/*. If DIR is unset or contains spaces/globs, this can expand into something far more destructive than intended. Always quote variable expansions unless you specifically need word-splitting. rm -rf "${DIR:?}"/* aborts the script when DIR is empty or unset, even without set -u.
  • Checking $? too late. $? reflects the exit status of the immediately preceding command — inserting an echo for debugging between the command and the check silently breaks it.
  • Using [ ] instead of [[ ]] for anything beyond the simplest POSIX comparisons. [[ ]] avoids word-splitting/globbing surprises and supports &&/||/regex matching directly.

⚠ Dangerous commands

  • Any script using rm -rf "$VAR" where $VAR could be empty or unset. Combined with set -u, an unset variable will stop the script (good) — but without set -u, rm -rf "$VAR"/ with an empty $VAR becomes rm -rf /, deleting the root filesystem (blocked by modern rm for the literal / path, but not for $VAR/subdir expanding unexpectedly).
  • Scripts that eval user-supplied input are a direct path to arbitrary code execution — avoid eval entirely unless you fully control every byte of the string being evaluated.

Review questions

  1. What does set -o pipefail change about how a pipeline's exit status is determined?
  2. Why does set -u catch bugs that set -e alone would miss?
  3. Why should #!/usr/bin/env bash generally be preferred over #!/bin/sh for scripts using bash-specific syntax?
  4. What's the practical difference between local name="$1" and name="$1" inside a function?