Writing a robust script

Strict mode, traps that clean up after a crash, and argument parsing that fails with a useful message.

Strict mode

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'          # a safer default separator for unquoted expansions
OptionEffect
-e / errexitExit as soon as a command returns non-zero
-u / nounsetTreat an unset variable as an error
-o pipefailA pipeline fails if any stage fails, not just the last
-x / xtracePrint each command; useful for debugging, but it leaks values into logs
IFSControls how unquoted expansions are split
  • set -e does not apply inside a condition or a &&/|| list, so it is not a substitute for checking results you care about.
  • Use ${VAR:-default} for optional values so -u does not abort on a variable you intended to leave unset.
  • Put the shebang and strict mode at the top of every script you keep; consistency matters more than which flavour you pick.

Traps and cleanup

tmpdir=$(mktemp -d)

cleanup() {
    local status=$?
    rm -rf "$tmpdir"
    exit "$status"
}
trap cleanup EXIT INT TERM

trap 'echo "error on line $LINENO" >&2' ERR

exec {lock_fd}>/var/lock/myscript.lock || { echo "already running" >&2; exit 1; }
  • trap ... EXIT runs on success, failure and interruption, which makes it the reliable place for cleanup.
  • Capture $? on the first line of the trap: any command inside it overwrites the original status.
  • Use mktemp -d instead of a fixed path in /tmp, or two concurrent runs will delete each other's files.
  • A lock file stops two copies of a cron job from overlapping and fighting.
⚠️
A trap on EXIT does not run if the shell is killed with SIGKILL. Never rely on cleanup for correctness: make the next run able to detect and repair the leftover state.

Arguments and usage

#!/usr/bin/env bash
set -euo pipefail

usage() {
    cat >&2 <<EOF
usage: $(basename "$0") [-e ENV] [-v] TARGET

  -e ENV   environment to deploy to (default: staging)
  -v       verbose output
EOF
    exit 2
}

verbose=false
env=staging

while getopts ":e:vh" opt; do
    case "$opt" in
        e) env=$OPTARG ;;
        v) verbose=true ;;
        h) usage ;;
        \?) echo "invalid option: -$OPTARG" >&2; usage ;;
        :)  echo "option -$OPTARG needs a value" >&2; usage ;;
    esac
done
shift $((OPTIND - 1))

target=${1:-}
[[ -n "$target" ]] || usage

if $verbose; then set -x; fi
echo "deploying to $env: $target"
  • shift $((OPTIND - 1)) drops the parsed options so "$@" holds only the positional arguments.
  • The leading colon in getopts ":e:vh" switches it to silent mode so you can print your own error messages.
  • -- ends option parsing explicitly, which lets a target name start with a dash.
  • Validate what you accept: required arguments present, files readable, numbers in range. Fail with a clear message and a non-zero status.

FAQ

Why does set -e not stop my script?
It does not apply to commands in conditions, in && or || lists, or inside while and until tests. Check those explicitly, and prefer if ! cmd; then ... so the failure is visible.
How do I debug a failing script?
Run bash -x script.sh, or add set -x after argument parsing. Combine it with a trap that reports $LINENO so you learn both the line and the values in play.

Variables, quoting and exit codes Conditionals and loops

Last refreshed 2026-09-18.