Common Bash Scripting Pitfalls and How to Avoid Them

Avoid common Bash scripting bugs with safer error handling, quoting, arrays, traps, and argument parsing.

Common Bash Scripting Pitfalls and How to Avoid Them

Bash scripting pitfalls usually show up when your script meets real filenames, missing variables, failed commands, or unexpected input. A script that works on your laptop can break in CI or production if it relies on loose defaults.

You do not need to make every shell script complicated. You do need to quote expansions, check failures intentionally, and test with names that contain spaces.

Set Safer Defaults Carefully

Many scripts start with:

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

That is a good baseline for many automation scripts, but each option has sharp edges:

  • set -e exits when a simple command fails, except in places such as if tests, parts of && and || lists, and some command substitutions.
  • set -u exits when you expand an unset variable.
  • set -o pipefail makes a pipeline fail if any command in the pipeline fails, not only the last command.

Use these options when early failure is safer than continuing. For commands where failure is expected, handle the status explicitly.

if ! grep -q "ready" status.txt; then
  echo "service is not ready yet"
  exit 1
fi

Quote Variable Expansions

Unquoted variables are the most common Bash bug. Bash performs word splitting and glob expansion on unquoted expansions, so a path like release notes/*.txt can become several arguments or match files you did not intend.

file="release notes.txt"

# Bad: breaks because the value is split into two words.
rm $file

# Good: passes one exact argument.
rm -- "$file"

Use -- before user-controlled filenames when a command supports it. That prevents a filename such as -rf from being interpreted as an option.

Use Arrays for Lists of Arguments

Do not store a command with arguments in one string and then run it. Quoting becomes fragile fast.

# Bad
flags="-a --exclude node_modules"
rsync $flags "$src" "$dest"

# Good
flags=(-a --exclude "node_modules")
rsync "${flags[@]}" "$src" "$dest"

Arrays preserve argument boundaries. That matters when an argument contains spaces, wildcard characters, or values that start with a dash.

Prefer $(...) Over Backticks

Backticks are hard to nest and easy to misread. Use $(...) for command substitution.

current_branch="$(git rev-parse --abbrev-ref HEAD)"
echo "building branch: $current_branch"

Keep command substitutions quoted unless you deliberately want word splitting.

Read Files Without Losing Data

This pattern looks harmless but breaks on spaces and can mangle backslashes:

for line in $(cat hosts.txt); do
  echo "$line"
done

Read files with while IFS= read -r instead.

while IFS= read -r host; do
  echo "checking $host"
done < hosts.txt

IFS= preserves leading and trailing whitespace. -r prevents backslash escapes from being interpreted.

Handle Temporary Files with mktemp and trap

Hardcoded temporary paths can collide with another process or leave stale files behind. Create a unique path and clean it up on exit.

tmp_file="$(mktemp)"
cleanup() {
  rm -f "$tmp_file"
}
trap cleanup EXIT

printf '%s\n' "work data" > "$tmp_file"

For directories, use mktemp -d and remove the directory in your cleanup function.

Parse Options with getopts

Manual argument parsing often misses edge cases. For short options, Bash's built-in getopts is usually enough.

verbose=false
output=""

while getopts ":vo:" opt; do
  case "$opt" in
    v) verbose=true ;;
    o) output="$OPTARG" ;;
    :)
      echo "Option -$OPTARG requires an argument" >&2
      exit 2
      ;;
    \?)
      echo "Unknown option: -$OPTARG" >&2
      exit 2
      ;;
  esac
done
shift "$((OPTIND - 1))"

getopts handles short flags such as -v and -o file. If your script needs long options like --output, write a careful parser or use a language with a stronger argument parsing library.

Check Commands That Can Fail

Do not assume a command worked because it printed something. Check important operations before using their output.

if ! archive="$(tar -czf app.tar.gz app 2>&1)"; then
  echo "archive failed: $archive" >&2
  exit 1
fi

For pipelines, enable pipefail when a failure in the middle should fail the whole pipeline.

set -o pipefail
journalctl -u api.service | grep -i "error"

Without pipefail, the pipeline status normally comes from the last command.

Avoid Bash When Portability Matters

If your script uses arrays, [[ ... ]], mapfile, or pipefail, it is a Bash script. Start it with:

#!/usr/bin/env bash

If you need POSIX sh portability, avoid Bash-only features and test with the shell your target system uses. Do not write a Bash script with #!/bin/sh and hope it behaves the same everywhere.

Takeaway

The fastest way to improve your Bash scripts is to test them with messy input: spaces in filenames, missing variables, empty files, and failing commands. Quote expansions, use arrays for argument lists, clean up temporary files with trap, and make failure paths explicit. Your future self will spend less time debugging scripts that only worked on perfect input.