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.