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 -eexits when a simple command fails, except in places such asiftests, parts of&&and||lists, and some command substitutions.set -uexits when you expand an unset variable.set -o pipefailmakes 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.