Abstract
Practical tips for writing safer Bash scripts. See how common mistakes affect arguments, errors, files, and cleanup. Check a complete example with ShellCheck and bashunit.
From time to time I write shell scripts. I usually work with other languages, so it is easy to forget the details of Bash. These are the practices I check before relying on a script.
The examples in this post use Bash. Some features, such as arrays, are not available in every shell. Run them with Bash.
Examples marked Incorrect deliberately show bugs. Run snippets in separate
shells. The complete script and tests at the end can be run together.
Use the right shebang
A shebang is the character sequence #! at the very beginning of a script
file. On systems such as Linux, it tells the system which interpreter to use
when executing the file directly.
The interpreter must exist. Its location can vary between systems.
The env utility finds it through the PATH environment variable.
TIP
Use /usr/bin/env to find Bash through PATH.
#!/usr/bin/env bash
This assumes env exists at /usr/bin/env. It selects the first Bash in PATH.
A fixed path such as #!/bin/bash also works when we control the system and
know where Bash is installed.
Choosing a different shell can break valid Bash code. For example, save this
as wrong-shell.sh:
#!/bin/sh
# Incorrect: this script uses a Bash array with a sh shebang.
files=('report draft.txt')
printf '%s\n' "${files[@]}"
On systems where sh is Dash, sh wrong-shell.sh fails at the array definition.
Use a Bash shebang and execute the file directly or with bash wrong-shell.sh.
Configure the shell
Bash continues after many command failures by default. It also allows unset variables. A pipeline usually reports only the exit status of its last command. These defaults can hide problems as scripts grow.
Use the builtin command set to change this behavior. Check its options with:
help set
TIP
Enable checks for command failures, unset variables, and failed pipelines.
# Exit on an unhandled non-zero status, with exceptions explained below.
set -o errexit
# Treat unset variables as an error when substituting.
set -o nounset
# Report the rightmost non-zero status in a pipeline, or zero if all succeed.
set -o pipefail
We can write these options together as set -euo pipefail.
A minus enables an option. A plus disables it. For example, set +e disables
errexit. Some failure examples disable checks to reproduce Bash’s defaults.
Without pipefail, a failed command can disappear behind a successful one:
# Incorrect: the pipeline hides the failure of false.
set +e
set +o pipefail
false | cat
printf 'Pipeline status: %s\n' "${?}" # Prints 0.
With pipefail enabled, this pipeline returns one. If errexit is enabled too,
the script exits before printf.
Create an entrypoint
Define a main function to make the entrypoint clear. Bash does not require it.
It separates function definitions from the code that starts execution.
TIP
Create an entrypoint to make control flow explicit.
#!/usr/bin/env bash
set -euo pipefail
# Main function of the script.
main() {
printf 'Hello from Bash\n'
}
# Entrypoint.
main "${@}"
Use local for variables inside functions. This reduces accidental changes
to variables used elsewhere in the script.
Without local, a helper can change the caller’s state:
# Incorrect: the function overwrites a global variable.
name='main'
rename() {
name='helper'
}
rename
printf '%s\n' "${name}" # Prints helper.
Use local name='helper' inside the function to keep the global value intact.
Use braces and quotes consistently
Braces mark the limits of a variable name. Double quotes prevent word splitting and filename expansion. Use both for variable expansions, including assignments.
Bash allows braces to be omitted in simple expansions. We keep them for consistency and to avoid ambiguity when adding a suffix.
backup="${source}_backup"
printf '%s\n' "${backup}"
Here, _backup is a suffix. The braces keep it outside the variable name.
For consistency, this post also uses braces for positional and special
parameters, such as "${1}", "${@}", and "${#}".
The following incorrect example deliberately omits braces:
# Incorrect: Bash reads source_backup as the entire variable name.
source='report'
source_backup='other'
printf '%s\n' "$source_backup" # Prints other, instead of report_backup.
An unquoted variable can be split into several arguments. Its contents can also expand into filenames if they contain wildcard characters. A path with spaces is enough to break a command.
This incorrect example deliberately omits double quotes:
# Incorrect: one filename becomes two arguments.
source='report draft.txt'
printf '<%s>\n' ${source}
# Prints <report> and <draft.txt> on separate lines.
Using "${source}" produces one line: <report draft.txt>.
TIP
Use braces and double quotes for variable expansions.
source='report draft.txt'
destination='report final.txt'
cp -- "${source}" "${destination}"
The -- tells cp to stop reading options. This also handles filenames that
start with a dash. Many commands support this convention. Check their manuals.
Quote command substitutions too, as in temporary_file="$(mktemp)". They use
parentheses instead of braces. Keep intentional glob patterns such as ./*.txt
unquoted so Bash can expand them into filenames.
Preserve argument boundaries
The "${@}" in our entrypoint preserves each argument separately. Use it to
forward arguments to another function or command.
For a list of options, use an array. Do not build a command in a string and
run it with eval.
options=(--recursive --verbose)
cp "${options[@]}" -- "${source}" "${destination}"
Each array element becomes one argument. Spaces inside an element remain part of that argument.
Using [*] instead joins the elements into one argument:
# Incorrect: the command receives one combined option string.
show_arguments() {
printf 'Arguments: %s\n' "${#}"
}
options=(--recursive --verbose)
show_arguments "${options[*]}" # Prints Arguments: 1.
show_arguments "${options[@]}" # Prints Arguments: 2.
Validate inputs and dependencies
Check inputs before doing any work. Start with the number of arguments. This
also avoids reading an unset "${1}" when nounset is enabled.
Reading it first produces a shell error instead of a useful usage message:
# Incorrect: running this script without arguments stops at printf.
set -u
printf 'Input: %s\n' "${1}"
Check the argument count before accessing the value:
if [[ "${#}" -ne 1 ]]; then
printf 'Usage: %s FILE\n' "${0}" >&2
exit 1
fi
input="${1}"
if [[ ! -f "${input}" || ! -r "${input}" ]]; then
printf 'Error: expected a readable file: %s\n' "${input}" >&2
exit 1
fi
Check external commands too. Bash being installed does not mean every utility we need is available.
if ! command -v sha256sum >/dev/null 2>&1; then
printf 'Error: sha256sum is required\n' >&2
exit 1
fi
For optional configuration, define a default explicitly. The following uses
info when LOG_LEVEL is unset or empty. It also works with nounset enabled.
log_level="${LOG_LEVEL:-info}"
Using "${LOG_LEVEL}" without a default stops the script when the variable is
unset and nounset is enabled.
Handle failures explicitly
Commands report success with exit status zero. A non-zero status can mean an
error or an expected result. For example, grep returns one when no match is
found.
Check the status and give the user a useful error message.
However, errexit is not a complete error handling strategy. Bash ignores it
in several contexts. These include commands used as conditions in if and
while, and most commands in && or || lists. Check important failures
explicitly.
For example, calling a function as an if condition makes Bash ignore
errexit inside that function:
# Incorrect: set -e does not stop this function at false.
set -e
prepare() {
false
printf 'Preparation completed\n'
}
if prepare; then
printf 'Proceeding\n'
fi
Both messages appear. The function returns the status of its last command. Check failures inside the function and return a non-zero status explicitly.
Without a check, a script can claim success after a failed operation:
# Incorrect: a failed copy does not stop this script.
set +e
cp -- './missing-source.txt' './result.txt'
printf 'Copy completed\n'
If the source does not exist, cp fails. The success message still appears.
The script also ends with status zero because printf succeeds.
TIP
Check important command results and send error messages to stderr.
if cp -- "${source}" "${destination}"; then
printf 'Copy completed\n'
else
printf 'Error: could not copy %s\n' "${source}" >&2
exit 1
fi
Use stdout for results and stderr for diagnostics. This allows other commands to consume the results without receiving error messages as input.
Inside a function, use return 1 to let the caller handle the failure.
Prefer printf for predictable output. Keep its format string fixed and pass
values as separate arguments.
Using a variable as the format string can change its contents:
# Incorrect: printf interprets the percent signs as format instructions.
message='Progress: %s%s'
printf "${message}" # Prints only Progress: followed by a space.
Use printf '%s\n' "${message}" to print the value literally.
Set the file permissions mask
It is common that scripts download or create files. To protect these files from
unauthorized access, configure the file creation permissions mask with umask.
The mask removes permission bits from newly created files and directories.
TIP
Use umask 077 when newly created files should be private.
# Remove read, write, and execute permissions for the group and others.
umask 077
For typical file creation, this gives files mode 600 and directories mode
700. The mask does not add permissions or change existing files. Choose
another mask when files must be shared.
A permissive mask can expose sensitive data. On Linux, run this in a directory
where credentials.txt does not exist:
# Incorrect: the new file is readable by the group and others.
umask 022
printf 'example-secret\n' > credentials.txt
stat -c '%a' credentials.txt # Prints 644.
Set the mask before creating the file. Changing it afterward does not change that file’s permissions.
Create temporary files safely
Predictable temporary filenames can collide with other scripts. They can also allow another user to create a symbolic link before our script writes the file.
Use mktemp to create the file safely. Do not use mktemp -u. That option only
generates a name and leaves file creation to us.
TIP
Create temporary files with mktemp and register cleanup immediately.
temporary_file="$(mktemp)"
trap 'rm -f -- "${temporary_file}"' EXIT
The EXIT trap removes the file when the shell exits. Single quotes delay
expansion until the trap runs. Keep the variable in scope until then. A local
variable inside main may no longer exist when the shell exits.
Cleanup cannot run after SIGKILL or a system crash.
Putting cleanup at the end is not enough. An earlier failure can skip it:
# Incorrect: no cleanup is registered before the failure.
set -e
temporary_file="$(mktemp)"
printf 'Temporary file: %s\n' "${temporary_file}"
false
rm -f -- "${temporary_file}" # Never runs.
The temporary file remains. Register the EXIT trap before doing work that
can fail.
Handle filenames safely
Do not parse the output of ls to get filenames. Do not turn find output into
a list with command substitution either. Word splitting can break filenames
containing spaces or newlines.
For files in one directory, use a glob. Enable nullglob so an unmatched pattern
produces no entries.
Without it, Bash keeps a pattern that matches no files:
# Incorrect: an empty directory still produces one loop iteration.
shopt -u nullglob
for file in ./*.txt; do
printf '%s\n' "${file}"
done
# Prints ./*.txt when there are no matching files.
Enable nullglob to skip the loop when nothing matches:
shopt -s nullglob
for file in ./*.txt; do
printf '%s\n' "${file}"
done
For recursive searches, use find with -exec. It passes filenames directly
to the command.
find . -type f -name '*.txt' -exec sha256sum -- {} +
Read text carefully
Plain read changes the input:
# Incorrect: read removes surrounding whitespace and interprets backslashes.
printf ' C:\\work \n' | while read line; do
printf '<%s>\n' "${line}"
done
# Prints <C:work>.
Use IFS= read -r to preserve surrounding whitespace and backslashes. The
extra condition also handles a last line without a newline:
while IFS= read -r line || [[ -n "${line}" ]]; do
printf '%s\n' "${line}"
done < "${input}"
Redirect the file into the loop. A loop at the end of a pipeline usually runs in a subshell. Variable changes inside it may be lost after the loop finishes.
For example, with Bash’s default pipeline behavior:
# Incorrect: the assignment happens in a subshell.
last_line=''
printf 'hello\n' | while IFS= read -r line; do
last_line="${line}"
done
printf 'Last line: <%s>\n' "${last_line}" # Prints Last line: <>.
Put the tips together
The following script computes and prints a file checksum. It uses a private
temporary file to demonstrate permissions and cleanup. It requires sha256sum,
which is common on Linux.
Relative paths get a ./ prefix. This makes a file named - a path instead of
the special operand that sha256sum uses for stdin.
#!/usr/bin/env bash
set -euo pipefail
umask 077
temporary_file=''
cleanup() {
if [[ -n "${temporary_file}" ]]; then
rm -f -- "${temporary_file}"
fi
}
main() {
if [[ "${#}" -ne 1 ]]; then
printf 'Usage: %s FILE\n' "${0}" >&2
exit 1
fi
local source="${1}"
if [[ "${source}" != /* ]]; then
source="./${source}"
fi
if [[ ! -f "${source}" || ! -r "${source}" ]]; then
printf 'Error: expected a readable file: %s\n' "${source}" >&2
exit 1
fi
local dependency
for dependency in mktemp sha256sum cat rm; do
if ! command -v "${dependency}" >/dev/null 2>&1; then
printf 'Error: %s is required\n' "${dependency}" >&2
exit 1
fi
done
temporary_file="$(mktemp)"
trap cleanup EXIT
if sha256sum -- "${source}" > "${temporary_file}"; then
cat -- "${temporary_file}"
else
printf 'Error: could not compute checksum for %s\n' "${source}" >&2
exit 1
fi
}
main "${@}"
Save it as checksum.sh and run it with a file path:
bash checksum.sh 'report draft.txt'
Check scripts before relying on them
Check syntax without executing the script:
bash -n checksum.sh
Then run ShellCheck. It detects common mistakes in quoting, conditions, portability, and other shell behavior.
shellcheck checksum.sh
These checks do not prove that a script works. Run it with inputs that can expose mistakes:
- Missing or extra arguments.
- Paths containing spaces or wildcard characters.
- Filenames starting with a dash.
- Missing commands and failed operations.
- Empty files and text without a final newline.
- Repeated runs with the same inputs.
For debugging, use bash -x checksum.sh FILE to trace commands. The trace can
include expanded secrets. Use it carefully when handling credentials.
Test scripts with bashunit
bashunit runs Bash test functions and checks their behavior with assertions. Use it to test success and failure cases automatically.
Install it with the bashunit installation guide. The following pins a version so local runs and CI use the same tool:
curl -fsSLo install-bashunit.sh https://bashunit.com/install.sh
bash install-bashunit.sh lib 0.51.0
mkdir -p tests
Keep checksum.sh in the project root. Save the following as
tests/checksum_test.sh. Names ending in _test.sh are discovered automatically.
Test functions start with test_. The set_up and tear_down hooks prepare
and clean up each test. See the bashunit test file documentation.
TIP
Test results, exit codes, error messages, and cleanup. Include inputs that can break the script.
#!/usr/bin/env bash
set_up() {
test_directory="$(mktemp -d)" || return 1
mkdir -- "${test_directory}/tmp" || return 1
printf 'hello\n' > "${test_directory}/report draft.txt"
}
tear_down() {
rm -rf -- "${test_directory:?}"
}
run_checksum() {
checksum_status=0
checksum_output="$(
TMPDIR="${test_directory}/tmp" bash checksum.sh "${@}" \
2>"${test_directory}/stderr"
)" || checksum_status="${?}"
checksum_error="$(cat -- "${test_directory}/stderr")"
}
test_handles_spaces_and_cleans_up() {
local expected_hash='5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03'
run_checksum "${test_directory}/report draft.txt"
assert_same '0' "${checksum_status}"
assert_same \
"${expected_hash} ${test_directory}/report draft.txt" \
"${checksum_output}"
assert_empty "${checksum_error}"
assert_is_directory_empty "${test_directory}/tmp"
}
test_rejects_missing_arguments() {
run_checksum
assert_same '1' "${checksum_status}"
assert_empty "${checksum_output}"
assert_contains 'Usage:' "${checksum_error}"
}
test_rejects_missing_files() {
run_checksum "${test_directory}/missing.txt"
assert_same '1' "${checksum_status}"
assert_empty "${checksum_output}"
assert_contains 'expected a readable file' "${checksum_error}"
}
test_cleans_up_after_a_command_failure() {
mkdir -- "${test_directory}/bin"
printf '#!/usr/bin/env bash\nexit 2\n' > "${test_directory}/bin/sha256sum"
chmod +x -- "${test_directory}/bin/sha256sum"
PATH="${test_directory}/bin:${PATH}" \
run_checksum "${test_directory}/report draft.txt"
assert_same '1' "${checksum_status}"
assert_empty "${checksum_output}"
assert_contains 'could not compute checksum' "${checksum_error}"
assert_is_directory_empty "${test_directory}/tmp"
}
The helper runs the script in a child Bash process. Its exit, shell options,
and traps do not affect the test runner. We capture the exit status before
reading stderr. Command substitution strips trailing newlines from the captured
text. The :? in teardown prevents cleanup with an unset or empty path.
The failure test puts a fake sha256sum first in PATH. It returns two to
exercise error handling.
Run the suite from the project root:
./lib/bashunit tests
The bashunit assertions check a known checksum, argument handling, errors, and cleanup. The suite should report four passing tests.
A test must also detect the bug it was written for. For example, temporarily remove the quotes around the source in the checksum command:
# Incorrect: the source path is split at spaces.
sha256sum -- ${source} > "${temporary_file}"
The test for paths containing spaces now fails. Restore the quotes and run
the suite again. Removing trap cleanup EXIT also fails the cleanup assertions.
These deliberate changes check that the tests catch real bugs.
For more details, check the Bash manual and the GNU Coreutils manual.