Abstract
These are the checks I use when writing Bash scripts, with examples of bugs in argument handling, errors, file operations, and cleanup. The post ends with a complete script and commands to check it with ShellCheck and test it with bashunit.
I write shell scripts from time to time. Most of my work is in other languages, so I tend to forget the details of Bash. These are the things I check before relying on a script.
Run the examples with Bash. They use features, such as arrays, that aren’t available in every shell.
Examples marked Incorrect contain deliberate bugs. Run each snippet in a
separate shell. You can run the complete script and tests at the end together.
Use the right shebang
A shebang starts with #! at the beginning of a script. On systems such as
Linux, it selects the interpreter when you execute the file directly.
The interpreter needs to be installed, but its location can vary between
systems. Use env to find it through the PATH environment variable.
TIP
Use /usr/bin/env to find Bash through PATH.
#!/usr/bin/env bash
This assumes env is installed at /usr/bin/env and selects the first Bash
in PATH. A fixed path such as #!/bin/bash also works if you control the
system and know where Bash is installed.
Bash code can fail when you run it with another shell. Save this example 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[@]}"
If sh is Dash, sh wrong-shell.sh fails at the array definition. Use a Bash
shebang, then execute the file directly or run bash wrong-shell.sh.
Configure the shell
By default, Bash continues after many command failures and allows unset variables. A pipeline usually reports only the exit status of its last command. These defaults can hide failures.
Use the builtin command set to change these defaults. Check the 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
You can combine these options as set -euo pipefail.
A minus enables an option; a plus disables it. For example, set +e disables
errexit. Some examples below disable checks to reproduce Bash’s defaults.
Without pipefail, a successful command can hide an earlier failure:
# Incorrect: the pipeline hides the failure of false.
set +e
set +o pipefail
false | cat
printf 'Pipeline status: %s\n' "${?}" # Prints 0.
With pipefail, the pipeline returns one. If you also enable errexit, the
script exits before reaching printf.
Create an entrypoint
I use a main function to separate function definitions from the code that
starts execution. Bash doesn’t require one, but it makes the entrypoint clear.
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 to avoid changing variables used
elsewhere in the script.
A helper without local can overwrite the caller’s variable:
# 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.
Use braces and quotes consistently
Braces mark the limits of a variable name. Double quotes prevent word splitting and filename expansion. I use both for variable expansions, including assignments.
Bash allows simple expansions without braces. I keep them for consistency and to make suffixes unambiguous.
backup="${source}_backup"
printf '%s\n' "${backup}"
The braces keep the _backup suffix outside the variable name. I also use
braces for positional and special parameters, such as "${1}", "${@}", and
"${#}".
Without braces, Bash reads the suffix as part of the variable name:
# 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 split into several arguments. Wildcard characters in its value can also expand into filenames. Even a path with spaces can break a command.
Without double quotes, one filename becomes two arguments:
# Incorrect: one filename becomes two arguments.
source='report draft.txt'
printf '<%s>\n' ${source}
# Prints <report> and <draft.txt> on separate lines.
Use "${source}" to get 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, so filenames starting with a dash
work too. Many commands support this convention. Check each command’s manual.
Quote command substitutions too: temporary_file="$(mktemp)". They use
parentheses instead of braces. Leave intentional glob patterns such as
./*.txt unquoted so Bash can expand them into filenames.
Preserve argument boundaries
The entrypoint uses "${@}" to preserve each argument separately. Use it when
forwarding arguments to another function or command.
Keep lists of options in arrays. Avoid building a command in a string and
running it with eval.
options=(--recursive --verbose)
cp "${options[@]}" -- "${source}" "${destination}"
Each array element becomes one argument, including any spaces in that element.
Using [*] joins all 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 the number of arguments before doing any work. With nounset enabled,
this also prevents you from reading an unset "${1}".
Reading "${1}" first gives a shell error before you can print a usage message:
# Incorrect: running this script without arguments stops at printf.
set -u
printf 'Input: %s\n' "${1}"
Check the argument count, then read 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 that the external commands you need are installed too. Having Bash doesn’t guarantee that those utilities are available.
if ! command -v sha256sum >/dev/null 2>&1; then
printf 'Error: sha256sum is required\n' >&2
exit 1
fi
Give optional configuration an explicit default. This uses info when
LOG_LEVEL is unset or empty, even with nounset enabled:
log_level="${LOG_LEVEL:-info}"
Without a default, "${LOG_LEVEL}" stops the script if the variable is unset
and nounset is enabled.
Handle failures explicitly
An exit status of zero means success. A non-zero status can signal an error
or an expected result: grep, for example, returns one when it finds no match.
Check the status so you can explain a failure to the user.
Don’t rely on errexit for all error handling. Bash ignores it in several
contexts, including commands used as conditions in if and while, and most
commands in && or || lists. Check important failures explicitly.
Calling a function as an if condition makes Bash ignore errexit inside
the 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.
A script without a check can report success after an operation fails:
# Incorrect: a failed copy does not stop this script.
set +e
cp -- './missing-source.txt' './result.txt'
printf 'Copy completed\n'
If the source doesn’t exist, cp fails, but the success message still appears.
The script exits with status zero because its last command, 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
Send results to stdout and diagnostics to stderr. Other commands can then consume the results without getting error messages as input.
Use return 1 inside a function when the caller should handle the failure.
I use printf for predictable output, with a fixed format string and values
passed as separate arguments.
A variable used as the format string can print differently than you expect:
# Incorrect: printf interprets the percent signs as format instructions.
message='Progress: %s%s'
printf "${message}" # Prints only Progress: followed by a space.
To print the value literally, use printf '%s\n' "${message}".
Set the file permissions mask
When a script downloads or creates files, use umask to control who can access
them. This file creation permissions mask removes permission bits from new
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 can’t add permissions or change existing files. Choose a
different mask if the files need to be shared.
A permissive mask can expose sensitive data. To see this on Linux, run the
following in a directory where credentials.txt doesn’t 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 you create the file. Changing it afterward leaves the file’s permissions as they are.
Create temporary files safely
Predictable temporary filenames can collide with files from other scripts. Another user could also create a symbolic link at that path before your script writes the file.
Create the file with mktemp. Avoid mktemp -u: it only generates a name,
leaving you to create the file.
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, so the variable needs to stay 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.
Cleanup at the end of a script can be skipped if an earlier command fails:
# 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 is left behind. Register the EXIT trap before running
commands that can fail.
Handle filenames safely
Avoid parsing ls output or turning find output into a list with command
substitution. Word splitting can break filenames that contain spaces or
newlines.
Use a glob for files in one directory. Enable nullglob so a pattern with no
matches produces no entries.
Without nullglob, Bash keeps the unmatched pattern:
# 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.
With nullglob enabled, the loop is skipped when nothing matches:
shopt -s nullglob
for file in ./*.txt; do
printf '%s\n' "${file}"
done
Use find with -exec for recursive searches. It passes filenames directly
to the command:
find . -type f -name '*.txt' -exec sha256sum -- {} +
Read text carefully
Plain read removes surrounding whitespace and interprets backslashes:
# 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 both. The extra condition below 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, so changes to variables inside it may be lost when it finishes.
With Bash’s default pipeline behavior, this assignment is lost:
# 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
This script computes and prints a file checksum. It uses a private temporary
file to show permissions and cleanup together. You’ll need sha256sum, which
is common on Linux.
The script prefixes relative paths with ./. A file named - then becomes a
path rather than 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 the script as checksum.sh and pass it a file path:
bash checksum.sh 'report draft.txt'
Check scripts before relying on them
Start by checking syntax without executing the script:
bash -n checksum.sh
Then run ShellCheck to detect common mistakes in quoting, conditions, portability, and other shell behavior:
shellcheck checksum.sh
These checks don’t prove the script works. Run it with inputs and conditions 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.
Use bash -x checksum.sh FILE to trace commands while debugging. The trace can
include expanded secrets, so take care when handling credentials.
Test scripts with bashunit
bashunit runs Bash test functions and checks their behavior with assertions. Use it to automate tests for success and failure cases.
Follow the bashunit installation guide to install it. Pin the 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 and save the tests below as
tests/checksum_test.sh. bashunit automatically discovers names ending in
_test.sh. Test functions start with test_, and 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, keeping its exit, shell
options, and traps from affecting the test runner. It captures the exit status
before reading stderr. Command substitution strips trailing newlines from the
captured text. In teardown, :? prevents cleanup with an unset or empty path.
The failure test places a fake sha256sum first in PATH. Its exit status of
two exercises the script’s error handling.
Run the suite from the project root:
./lib/bashunit tests
The bashunit assertions check the output against a known checksum, along with argument handling, errors, and cleanup. You should get four passing tests.
Check that a test catches the bug it’s meant to detect. 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 rerun
the suite. Removing trap cleanup EXIT also makes the cleanup assertions fail.
These deliberate bugs check whether the tests detect the failures they cover.
Keep the Bash manual and the GNU Coreutils manual handy when writing scripts.