A script that carries on after an error does not handle errors: it ignores them. Without set -euo pipefail, a failing mv still prints "OK". Without read -r, a typed backslash disappears silently. A few lines of protection are enough to turn a fragile script into a script that is reliable in production. Every behaviour described here was measured on Bash 5.2 (Debian 13).
What you will learn
- Turn on
set -euo pipefailto stop the script at the first error - Use
read -rand quote variables to avoid word splitting - Validate inputs with
[[ ]]tests before any destructive action - Catch unexpected errors with
trap - Create safe temporary files with
mktemp - Analyse and fix a script with shellcheck
When is it useful?
A script that works in development can fail silently in production, where paths differ, variables are missing, and nobody watches the error output:
- a backup script that carries on after a failed
mvand marks the operation as successful - a deployment script that reads an empty
$CONFIG_FILEbecause the variable was not exported - a
cat log | grep ERR | wc -lpipeline that returns 0 even thoughcatfailed - a predictable temporary file in
/tmpreplaced by a symbolic link before the write - a plain-text password in a script versioned on GitHub
The techniques of this guide apply to any script meant to run in production, in cron, or in CI, as soon as you are not there to watch the output.
A script without protections: the problem
Here is move.sh, a simple script but full of traps. It works on a tidy file name, and breaks on the first real case:
#!/bin/bashecho "Source file:"read src
echo "Destination file:"read dst
mv $src $dstecho "Move done."The table lists the four problems of this script, with their concrete consequence. Each one is fixed by a dedicated protection, presented in the following sections.
| Problem | Consequence |
|---|---|
read without -r | The backslash is removed: typing a\nb gives anb (measured) |
$src and $dst without quotes | A space in the name splits the argument into several words |
| No validation | When the source file does not exist, mv fails but "Move done." still shows |
No set -e | A non-zero exit code does not stop the execution |
set -euo pipefail
Always place this line right after the shebang, so that the first command is already protected. It combines three distinct options:
set -euo pipefail-e: stop at the first error
Without -e, a failure goes unnoticed: the script moves on to the next line as if nothing happened, and prints a misleading message:
#!/bin/bashrm /no/such/fileecho "Clean-up done." # still showsrm: cannot remove '/no/such/file': No such file or directoryClean-up done.With set -e, the script stops after rm, with the exit code of the failing command, and does not print the misleading message.
The ((n++)) trap under set -e
Under set -e, an ((n++)) counter kills the script on the first turn when n is 0. It is the most vicious trap of set -e, because it hits a mundane idiom and without the slightest message. The cause: ((expression)) returns an exit code derived from the arithmetic value, and n++ is a post-increment, it evaluates to the old value of n. Starting from n=0, ((n++)) is therefore worth 0, which the shell turns into exit code 1 (0 = "false"), and set -e stops everything.
#!/usr/bin/env bashset -euo pipefailn=0((n++)) # exit code 1 (old value = 0), the script dies hereecho "never reached"The irony: as soon as n is 1 or more, ((n++)) returns the non-zero old value, hence code 0, and everything passes. The bug only fires on the first increment and only from zero, which makes it hard to spot on review. Three safe forms:
n=$((n + 1)) # assignment: always exit code 0((++n)) # pre-increment: evaluates the NEW value (1), code 0((n++)) || true # neutralises the exit code-u: forbid undefined variables
With -u, reading a variable never defined stops the script instead of replacing it with an empty string:
set -uecho "Directory: $DIRECTORY"bash: DIRECTORY: unbound variableWithout -u, the empty variable would be substituted silently, which becomes dangerous in rm -rf "$dir/": with dir missing, the command targets the root.
-o pipefail: detect errors in pipelines
By default, only the last command of a pipeline sets its exit code:
cat missing_file.txt | grep "pattern"echo $? # returns 1 (grep without results), but the failure of cat is ignoredWith set -o pipefail, the pipeline returns the code of the last failing command, the rightmost one, or 0 when all succeed. Measured: (exit 2) | (exit 3) | true returns 3 with pipefail, and 0 without. Combined with -e, a failing link stops the script, even when the last command succeeds.
Validate and quote variables
read -r and double quotes
read -r preserves backslashes as they are. Double quotes protect against word splitting and globbing:
read -r src # -r: backslashes preservedmv "$src" "$dst" # quotes: a single argument even with spacesWithout quotes, if src="my report.txt" then mv $src $dst passes three arguments to mv: my, report.txt and the value of $dst. The move fails, or worse, targets another file.
Test before acting
Validate every input before any destructive operation. The error function, defined further down in the complete script, prints the message and exits with a failure:
[[ -z "$src" ]] && error "Source file not specified."[[ -f "$src" ]] || error "The file '$src' does not exist."
dst_dir=$(dirname "$dst")[[ -w "$dst_dir" ]] || error "No write permission in '$dst_dir'."The most useful tests to validate an input fit in six operators. They are detailed in Bash conditions and tests, with their quoting traps:
| Test | Meaning |
|---|---|
-z "$var" | Empty or unset variable |
-n "$var" | Non-empty variable |
-f "$path" | Existing regular file |
-d "$path" | Existing directory |
-w "$path" | Writable |
-r "$path" | Readable |
trap, catch signals
trap runs a command when the script receives a signal (INT, TERM) or a Bash pseudo-signal (ERR, EXIT, DEBUG).
Catch unexpected errors (ERR)
error() { echo "Error: $1" >&2 exit 1}
trap 'error "Unexpected interruption at line $LINENO."' ERRWhen a command fails with set -e active, trap ERR runs before exiting, handy to print a contextual message with the line number. The trap also works without set -e (measured): the handler runs, then the script carries on, unless the handler itself calls exit.
Clean up on exit (EXIT)
trap ... EXIT runs on every exit of the script: success, error, Ctrl+C (SIGINT) or kill (SIGTERM), two cases measured. Only a kill -9 (SIGKILL) prevents it, since that signal cannot be caught:
tmpfile=$(mktemp)trap 'rm -f "$tmpfile"' EXIT
# The file is deleted even if the script fails along the waymktemp, safe temporary files
Never create a temporary file with a predictable name (/tmp/myscript.tmp): an attacker can create before you a symbolic link with that name pointing to a sensitive file, which your script will overwrite with its rights.
tmpfile=$(mktemp) # Creates /tmp/tmp.xK3pQr (unique name, permissions 600)tmpdir=$(mktemp -d) # Creates a temporary directoryCombined with trap EXIT, deletion is guaranteed even on error. The example extracts the warnings of the system journal for the current boot:
tmpfile=$(mktemp)trap 'rm -f "$tmpfile"' EXIT
journalctl -p warning -b --no-pager > "$tmpfile"echo "$(wc -l < "$tmpfile") warning lines found"No hard-coded secrets
A secret raises two distinct problems: where it is stored, and how it is passed to the program that uses it. The first is the versioned file; the second is the command line, readable by every account on the machine while the program runs.
# Not to do: hard-coded secret, then passed as an argumentDB_PASSWORD="mysecret"mysql -u backup -p"$DB_PASSWORD"Store the secret outside the script. An environment variable exported before the call, or a configuration file in chmod 600, never versioned, keep the secret out of the repository:
# config.conf: readable by its owner only, never versionedDB_PASSWORD="mysecret"chmod 600 config.confsource config.confDo not pass it as an argument. Even read from a variable, -p"$DB_PASSWORD" puts the secret in the process arguments, which ps shows to every account on the machine: it is measured with curl in Download files. Prefer the option file the command can read itself, such as --defaults-extra-file for the MySQL client:
# /etc/backup/mysql.cnf, in chmod 600[client]user=backuppassword=mysecretmysql --defaults-extra-file=/etc/backup/mysql.cnfThe complete script
Here is robust_move.sh, the fixed version of move.sh. It brings together the four protections: strict options, reads with read -r, validations before the action, and an error handler:
#!/bin/bashset -euo pipefail
# ── Error handling ─────────────────────────────────────────────────────────────error() { echo "Error: $1" >&2 exit 1}
trap 'error "Unexpected interruption at line $LINENO."' ERR
# ── Safe reads ─────────────────────────────────────────────────────────────────echo "Source file:"read -r src
echo "Destination file:"read -r dst
# ── Validations ────────────────────────────────────────────────────────────────[[ -z "$src" ]] && error "Source file not specified."[[ -z "$dst" ]] && error "Destination file not specified."[[ -f "$src" ]] || error "The file '$src' does not exist."
dst_dir=$(dirname "$dst")[[ -w "$dst_dir" ]] || error "No write permission in '$dst_dir'."
# ── Action ─────────────────────────────────────────────────────────────────────mv -- "$src" "$dst"echo "OK: '$src' moved to '$dst'."exit 0The double dash -- before the arguments of mv protects against file names starting with -, which mv would otherwise take for an option. Quotes handle spaces, -- handles dashes: they are two distinct protections.
shellcheck and shfmt
shellcheck analyses a script statically and reports bad practices before it runs. It installs with apt install shellcheck on Debian and Ubuntu; on RHEL and its derivatives, it comes from EPEL, under the package name ShellCheck.
shellcheck move.shOn the fragile script from the start, shellcheck 0.10 reports the three defects of reading and quoting, with their real line number (shortened output):
In move.sh line 3:read src^--^ SC2162 (info): read without -r will mangle backslashes.
In move.sh line 6:read dst^--^ SC2162 (info): read without -r will mangle backslashes.
In move.sh line 8:mv $src $dst ^--^ SC2086 (info): Double quote to prevent globbing and word splitting. ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.Each code (SC2162, SC2086) points to a detailed explanation on shellcheck.net/wiki/SCxxxx. The missing validation and set -e, on the other hand, are not a matter of static analysis: shellcheck does not report them.
shfmt reformats the source code consistently: indentation, spaces, line breaks. It is packaged in Debian 13 (version 3.8.0):
sudo apt install shfmtshfmt -i 4 -w my_script.sh # 4-space indentation, rewrite in placeshfmt -d my_script.sh # show the differences without changing anythingTroubleshooting
The symptoms below come from the strict options themselves: a protected script stops more often, and that is intended. The reflex is to read the exit code and the line printed by the trap ERR.
| Symptom | Likely cause | Solution |
|---|---|---|
| The script stops on a harmless command | -e stops on any non-zero code | Protect with cmd || true or if cmd; then ... fi |
unbound variable on an initialised variable | -u catches a misspelt name | Check the exact spelling and initialise: var="" |
trap ERR does not fire | Command tested by if, && or ||, trap set after the error, or failure inside a function without set -E | Set trap at the top of the script, add set -E for functions |
mktemp: failed to create file | /tmp full or mounted read-only | df /tmp then clean up orphan files |
| The script works interactively but not in cron | Different PATH in cron | Use full paths (/bin/mv, /usr/bin/grep) |
Key points
set -euo pipefailis the first line after the shebang in every production script.read -rpreserves backslashes; double quotes protect against word splitting.- Always validate inputs before a destructive operation (
mv,rm,cp). trap 'cmd' ERRcatches unexpected errors;trap 'cmd' EXITguarantees the clean-up.mktempgenerates a safe unique name, never use/tmp/myscript.tmp.- shellcheck detects most errors before running, to launch before every commit.
Knowledge check
Check that the essentials of this guide stuck. The questions cover only what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Bash functions: declare, call, return
A new full quiz with random questions
Work again only on the questions you missed