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.