Writing Bash Scripts That Don't Break
Most shell scripts work fine until a file name has a space in it or a command fails halfway through. Learn strict mode, quoting, safe temporary files and cleanup traps, then put it all together in a real backup script.
On this page
- Start every script the same way
- set -e: stop on the first error
- set -u: no undefined variables
- set -o pipefail: failures inside pipelines
- Quote your variables
- Read files line by line correctly
- Clean up with trap
- Functions, arguments and errors
- A complete example: a backup script
- Let ShellCheck review your scripts
- Recap
Bash scripts tend to start as a few commands pasted into a file. They work on your machine, with your files, on a good day. Then one evening a file name contains a space, a disk fills up, or a command fails halfway through, and the script keeps going as if nothing happened, sometimes deleting the wrong things on the way.
The fixes are few and mechanical. This article goes through the habits that make scripts fail loudly and safely, then combines them in a backup script you can actually use. It assumes you're comfortable with the commands in Essential Linux Commands Every Developer Should Know.
Start every script the same way#
#!/usr/bin/env bash
set -euo pipefailThe first line (the shebang) says which program runs the file. /usr/bin/env bash finds bash wherever it's installed, which matters on macOS and BSD. Make the file executable once with chmod +x script.sh and run it as ./script.sh.
The second line turns on what's often called strict mode. Each letter fixes a different silent failure.
set -e: stop on the first error#
By default Bash ignores failures and runs the next line anyway:
cd /var/www/myapp-typo # fails: No such file or directory
rm -rf ./* # ...and now runs in whatever directory you were inWith set -e, the script exits as soon as a command fails (returns a non-zero exit code). Failures that are expected, such as a grep that finds nothing, can be handled explicitly:
if grep -q "ERROR" app.log; then
echo "Errors found"
fi
grep -q "ERROR" app.log || echo "No errors" # || also counts as handling itWhere set -e doesn't help
set -e is ignored in conditions (if, while, &&, ||), and it has two well-known blind spots:
local out=$(some_command)never fails, because the exit code you get is the one fromlocal. Declare and assign on separate lines:local outthenout=$(some_command).((count++))returns 1 whencountwas 0, which kills the script underset -e. Usecount=$((count + 1))instead.
set -u: no undefined variables#
A typo in a variable name normally expands to an empty string, silently:
name="Ada"
echo "Hello, $nme" # prints "Hello, " without set -uThat's annoying in an echo, and dangerous in rm -rf "$build_dir/"*. With set -u, using an unset variable is an error:
./greet.sh: line 3: nme: unbound variableFor variables that are allowed to be missing, give a default with ${var:-default}, or fail with your own message with ${var:?message}:
port=${PORT:-8080} # use $PORT if set, otherwise 8080
: "${API_KEY:?API_KEY must be set}" # stop with this error if it's missingset -o pipefail: failures inside pipelines#
A pipeline's exit code is normally that of its last command, so failures earlier in the pipe vanish:
$ false | true; echo $?
0
$ set -o pipefail
$ false | true; echo $?
1Without it, mysqldump mydb | gzip > backup.sql.gz "succeeds" even when the dump fails, because gzip happily compresses an empty input. With pipefail, the pipeline fails if any command in it fails.
Quote your variables#
This is the single biggest source of shell bugs. An unquoted $var is split on spaces and then expanded as a wildcard pattern:
file="my report.txt"
rm $file # rm: cannot remove 'my': No such file or directory
# rm: cannot remove 'report.txt': No such file or directory
rm "$file" # removes "my report.txt"And if a variable contains *, an unquoted expansion turns it into a list of files in the current directory. The rule is simple: always put double quotes around variables and command substitutions: "$file", "$(date +%F)", "${files[@]}". The rare exceptions are deliberate, and deserve a comment.
To pass all of a script's arguments on to another command, use "$@". It keeps each argument intact, even when it contains spaces; $* and unquoted $@ don't:
# called as: ./wrapper.sh "a b" c
for arg in "$@"; do echo "[$arg]"; done # [a b] [c]
for arg in $*; do echo "[$arg]"; done # [a] [b] [c]Use [[ ]] for tests
In Bash, prefer [[ -f "$file" ]] to [ -f "$file" ]. It doesn't split words, so a missing quote can't break it, and it supports &&, || and pattern matching like [[ $name == *.txt ]].
Read files line by line correctly#
for line in $(cat file) loops over words, not lines, and expands wildcards on the way. The correct idiom is:
while IFS= read -r line; do
echo "Processing: $line"
done < hosts.txtIFS= keeps leading and trailing spaces, and -r stops backslashes from being treated as escapes.
Clean up with trap#
Scripts often create temporary files. mktemp creates one with a unique, unguessable name (never hard-code /tmp/myscript.tmp), and trap ... EXIT guarantees it's removed however the script ends: normally, because of an error under set -e, or when you press Ctrl+C:
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://example.com/data.csv -o "$tmpdir/data.csv"
# ...work with the file; no matter what happens, $tmpdir is deleted at the endNote the single quotes: $tmpdir is expanded when the trap runs, not when it's defined. Only kill -9 (SIGKILL) skips the trap, because nothing can catch that signal.
Functions, arguments and errors#
die() {
echo "Error: $*" >&2 # error messages go to stderr
exit 1
}
require_dir() {
local dir=$1 # local: don't leak into the rest of the script
[[ -d "$dir" ]] || die "'$dir' is not a directory"
}Inside a function, $1, $2… and "$@" are the function's own arguments. Use local for every variable a function creates, print errors to standard error (>&2) so they don't mix with real output, and exit with a non-zero code so the caller (a person, cron or CI) knows something went wrong.
A complete example: a backup script#
Here's a script that archives a directory, keeps only the newest few archives, and puts everything above into practice:
#!/usr/bin/env bash
# backup.sh: archive a directory and keep only the newest few archives.
set -euo pipefail
usage() {
echo "Usage: $0 SOURCE_DIR DEST_DIR [KEEP]" >&2
exit 2
}
[[ $# -ge 2 ]] || usage
src=$1
dest=$2
keep=${3:-7}
[[ -d "$src" ]] || { echo "Error: '$src' is not a directory" >&2; exit 1; }
mkdir -p "$dest"
# Write to a temporary file first, so a failed run never leaves a half-written archive behind
tmpfile=$(mktemp "$dest/.backup.XXXXXX")
trap 'rm -f "$tmpfile"' EXIT
name="$(basename "$src")-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$tmpfile" -C "$(dirname "$src")" "$(basename "$src")"
mv "$tmpfile" "$dest/$name"
echo "Created $dest/$name"
# The timestamp in the name sorts alphabetically, so the glob lists the oldest first
archives=("$dest/$(basename "$src")"-*.tar.gz)
excess=$(( ${#archives[@]} - keep ))
for (( i = 0; i < excess; i++ )); do
rm -- "${archives[i]}"
echo "Removed ${archives[i]}"
done$ ./backup.sh "my site" backups 2
Created backups/my site-20261029-081312.tar.gz
$ ./backup.sh "my site" backups 2
Created backups/my site-20261029-081313.tar.gz
$ ./backup.sh "my site" backups 2
Created backups/my site-20261029-081314.tar.gz
Removed backups/my site-20261029-081312.tar.gzA few details worth noticing:
- It handles a source directory with a space in its name, because every variable is quoted.
- The archive is written under a temporary name and renamed at the end. Renaming within one directory is atomic, so the backups folder never contains a half-written archive, even if
tarfails or the disk fills up. (mktempalso makes the file readable only by you, which suits a backup.) rm --marks the end of options, so a file name that starts with-can't be mistaken for one.- Wrong usage exits with code 2 and real errors with code 1, so a
cronjob or CI pipeline can tell them apart.
Let ShellCheck review your scripts#
ShellCheck is a linter for shell scripts. It catches missing quotes, the local trap, $* vs "$@" and dozens of other mistakes, each with an explanation. Install it with sudo apt install shellcheck (or brew install shellcheck), run shellcheck backup.sh, and add it to your editor or CI. It's the fastest way to absorb all of these habits.
Recap#
- Start with
#!/usr/bin/env bashandset -euo pipefail, and know whereset -edoesn't reach. - Quote every expansion:
"$var","$(cmd)","$@". - Use
${var:-default}and${var:?message}for optional and required values. - Create temporary files with
mktempand remove them withtrap '...' EXIT. - Send errors to
>&2, exit with meaningful codes, and run ShellCheck.