Writing safe, readable Bash scripts

Bash is useful for glue code, local automation and small operational tasks. It becomes risky when scripts hide failures, split data accidentally or depend on interactive shell hab…

Bash is useful for glue code, local automation and small operational tasks. It becomes risky when scripts hide failures, split data accidentally or depend on interactive shell habits. Safe Bash is explicit about its inputs, exits and quoting.

Start with the interpreter and shell options

Use an interpreter line that matches the script you are writing. If the script uses Bash arrays, [[ ... ]], process substitution or pipefail, it is a Bash script and should not claim to be portable sh.

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

set -e exits after many unhandled command failures. set -u treats unset variables as errors. set -o pipefail makes a pipeline fail when any command in the pipeline fails. These options are useful, but they are not a substitute for clear error handling. Bash documents exceptions for errexit, including commands used in the test of a conditional and parts of && or || lists.

Use explicit checks where failure is expected.

if ! grep -q "ready" status.txt; then
  echo "status.txt does not contain ready" >&2
  exit 1
fi

Quote variables by default

Unquoted parameter expansion can trigger word splitting and pathname expansion. That can turn one value into many arguments, or match files in the current directory. Quote variables unless you intentionally want splitting.

src="$1"
dest="$2"

cp -- "$src" "$dest"

Use arrays when you need to build a command with optional arguments.

args=(--recursive)

if [ "${verbose:-}" = "1" ]; then
  args+=(--verbose)
fi

cp "${args[@]}" -- "$src" "$dest"

Do not store a command line in a string and then run it. Store arguments as an array and execute the command directly.

Treat input as data, not code

Use read -r so backslashes are read literally. Set IFS= for line-oriented reads where leading and trailing whitespace matters.

while IFS= read -r line; do
  printf '%s\n' "$line"
done < input.txt

Use printf instead of echo for data. echo has portability and option parsing edge cases. printf gives clear, predictable formatting.

printf '%s\n' "$message"

When reading paths from another command, avoid whitespace-delimited loops. Prefer null-delimited output and input where available.

find . -type f -name '*.log' -print0 |
  while IFS= read -r -d '' path; do
    gzip -- "$path"
  done

Make failures visible

Print errors to standard error and exit with a non-zero status.

fail() {
  printf 'error: %s\n' "$*" >&2
  exit 1
}

[ "$#" -eq 2 ] || fail "usage: deploy SOURCE DEST"

Use a trap for cleanup. Keep cleanup idempotent, because it may run after partial failure.

tmpdir="$(mktemp -d)"
cleanup() {
  rm -rf -- "$tmpdir"
}
trap cleanup EXIT

Use trap for cleanup, not for hiding errors. If a script has several important steps, print the step before running it or wrap it in a small function with a clear name.

Keep scripts small and reviewable

Put constants near the top. Put reusable operations in functions. Prefer local variables inside functions.

copy_assets() {
  local src_dir="$1"
  local dest_dir="$2"

  mkdir -p -- "$dest_dir"
  cp -R -- "$src_dir"/. "$dest_dir"/
}

Avoid clever one-liners in scripts. A pipeline that is easy to paste into a terminal can be hard to debug in CI. Split complex operations into named steps and intermediate files when that improves reviewability.

Validate arguments and environment

Check required commands before using them.

command -v git >/dev/null 2>&1 || fail "git is required"
command -v jq >/dev/null 2>&1 || fail "jq is required"

Validate files and directories before destructive operations.

[ -d "$build_dir" ] || fail "build directory does not exist: $build_dir"
[ "$build_dir" != "/" ] || fail "refusing to remove /"
rm -rf -- "$build_dir"

Use parameter expansion for defaults only when the default is intentional.

profile="${PROFILE:-dev}"

Lint and test shell scripts

Run a syntax check before execution.

bash -n scripts/deploy.sh

Run ShellCheck and fix warnings unless there is a documented reason not to. ShellCheck catches common quoting, expansion and portability issues, including unquoted variables that may split or glob.

shellcheck scripts/deploy.sh

Test scripts with filenames that contain spaces, empty inputs, missing commands and failing subprocesses. Most shell bugs appear at boundaries, not in the happy path.

Know when not to use Bash

Use Bash for orchestration. Do not use it for complex data structures, long-lived services or heavy parsing of JSON, XML or YAML. Call a real parser or write a small program in a language with structured data types.

A Bash script is at its best when it coordinates existing tools, checks their exit statuses and leaves a readable audit trail.

Conclusion

Safe Bash is mostly discipline. Use Bash features only when the script is declared as Bash, quote data, prefer arrays over command strings, make failure explicit and lint every script. The result is not fancy, but it is much easier to trust during a release or incident.