Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Robust Bash scripts: set -euo pipefail, trap and shellcheck

By updated

Improve this page

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 pipefail to stop the script at the first error
  • Use read -r and 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 mv and marks the operation as successful
  • a deployment script that reads an empty $CONFIG_FILE because the variable was not exported
  • a cat log | grep ERR | wc -l pipeline that returns 0 even though cat failed
  • a predictable temporary file in /tmp replaced 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/bash
echo "Source file:"
read src
echo "Destination file:"
read dst
mv $src $dst
echo "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.

ProblemConsequence
read without -rThe backslash is removed: typing a\nb gives anb (measured)
$src and $dst without quotesA space in the name splits the argument into several words
No validationWhen the source file does not exist, mv fails but "Move done." still shows
No set -eA 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:

Terminal window
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/bash
rm /no/such/file
echo "Clean-up done." # still shows
rm: cannot remove '/no/such/file': No such file or directory
Clean-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 bash
set -euo pipefail
n=0
((n++)) # exit code 1 (old value = 0), the script dies here
echo "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:

Terminal window
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:

Terminal window
set -u
echo "Directory: $DIRECTORY"
bash: DIRECTORY: unbound variable

Without -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:

Terminal window
cat missing_file.txt | grep "pattern"
echo $? # returns 1 (grep without results), but the failure of cat is ignored

With 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:

Terminal window
read -r src # -r: backslashes preserved
mv "$src" "$dst" # quotes: a single argument even with spaces

Without 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:

Terminal window
[[ -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:

TestMeaning
-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)

Terminal window
error() {
echo "Error: $1" >&2
exit 1
}
trap 'error "Unexpected interruption at line $LINENO."' ERR

When 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:

Terminal window
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT
# The file is deleted even if the script fails along the way

mktemp, 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.

Terminal window
tmpfile=$(mktemp) # Creates /tmp/tmp.xK3pQr (unique name, permissions 600)
tmpdir=$(mktemp -d) # Creates a temporary directory

Combined with trap EXIT, deletion is guaranteed even on error. The example extracts the warnings of the system journal for the current boot:

Terminal window
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.

Terminal window
# Not to do: hard-coded secret, then passed as an argument
DB_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:

Terminal window
# config.conf: readable by its owner only, never versioned
DB_PASSWORD="mysecret"
Terminal window
chmod 600 config.conf
source config.conf

Do 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=backup
password=mysecret
Terminal window
mysql --defaults-extra-file=/etc/backup/mysql.cnf

The 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/bash
set -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 0

The 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.

Terminal window
shellcheck move.sh

On 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):

Terminal window
sudo apt install shfmt
shfmt -i 4 -w my_script.sh # 4-space indentation, rewrite in place
shfmt -d my_script.sh # show the differences without changing anything

Troubleshooting

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.

SymptomLikely causeSolution
The script stops on a harmless command-e stops on any non-zero codeProtect with cmd || true or if cmd; then ... fi
unbound variable on an initialised variable-u catches a misspelt nameCheck the exact spelling and initialise: var=""
trap ERR does not fireCommand tested by if, && or ||, trap set after the error, or failure inside a function without set -ESet trap at the top of the script, add set -E for functions
mktemp: failed to create file/tmp full or mounted read-onlydf /tmp then clean up orphan files
The script works interactively but not in cronDifferent PATH in cronUse full paths (/bin/mv, /usr/bin/grep)

Key points

  • set -euo pipefail is the first line after the shebang in every production script.
  • read -r preserves backslashes; double quotes protect against word splitting.
  • Always validate inputs before a destructive operation (mv, rm, cp).
  • trap 'cmd' ERR catches unexpected errors; trap 'cmd' EXIT guarantees the clean-up.
  • mktemp generates 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

6 questions
6 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn