LinuxIntermediate

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.

A terminal running a Bash script that starts with set -euo pipefail
On this page

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#

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

The 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 the mistake cd /bad/path rm -rf ./* without it rm runs in the wrong place with it script stops after cd set -u the mistake echo "Hi, $nme" without it prints “Hi, ” and goes on with it unbound variable error set -o pipefail the mistake mysqldump db | gzip without it dump fails, exit code 0 with it exit code 1: you notice
What strict mode catches: set -e stops after a failed cd, set -u turns a misspelled variable into an error, and pipefail makes a pipeline fail when any command in it fails

set -e: stop on the first error#

By default Bash ignores failures and runs the next line anyway:

Bash
cd /var/www/myapp-typo        # fails: No such file or directory
rm -rf ./*                    # ...and now runs in whatever directory you were in

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

Bash
if grep -q "ERROR" app.log; then
    echo "Errors found"
fi

grep -q "ERROR" app.log || echo "No errors"   # || also counts as handling it

Where 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 from local. Declare and assign on separate lines: local out then out=$(some_command).
  • ((count++)) returns 1 when count was 0, which kills the script under set -e. Use count=$((count + 1)) instead.

set -u: no undefined variables#

A typo in a variable name normally expands to an empty string, silently:

Bash
name="Ada"
echo "Hello, $nme"            # prints "Hello, " without set -u

That's annoying in an echo, and dangerous in rm -rf "$build_dir/"*. With set -u, using an unset variable is an error:

Text
./greet.sh: line 3: nme: unbound variable

For variables that are allowed to be missing, give a default with ${var:-default}, or fail with your own message with ${var:?message}:

Bash
port=${PORT:-8080}                          # use $PORT if set, otherwise 8080
: "${API_KEY:?API_KEY must be set}"         # stop with this error if it's missing

set -o pipefail: failures inside pipelines#

A pipeline's exit code is normally that of its last command, so failures earlier in the pipe vanish:

Bash
$ false | true; echo $?
0
$ set -o pipefail
$ false | true; echo $?
1

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

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

Bash
# 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:

Bash
while IFS= read -r line; do
    echo "Processing: $line"
done < hosts.txt

IFS= 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:

Bash
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 end

Note 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#

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

backup.shBash
#!/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
Bash
$ ./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.gz

A 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 tar fails or the disk fills up. (mktemp also 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 cron job 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 bash and set -euo pipefail, and know where set -e doesn't reach.
  • Quote every expansion: "$var", "$(cmd)", "$@".
  • Use ${var:-default} and ${var:?message} for optional and required values.
  • Create temporary files with mktemp and remove them with trap '...' EXIT.
  • Send errors to >&2, exit with meaningful codes, and run ShellCheck.