Bash is the lingua franca of automation on Linux and macOS. In 2026, it ships on every server, every CI runner, and every developer machine. The problem is not the language — it is that most Bash scripts are written without safety defaults, making them fragile, error-prone, and hard to debug. This guide fixes that.
What changed in 2026
- ShellCheck is table-stakes — every CI pipeline should run
shellcheck on .sh files; most linters include it.
- POSIX compliance matters more — with Alpine Linux and minimal containers dominant,
/bin/sh often means ash or dash, not Bash. Know the difference.
bash v5.2 features — associative arrays, ${var@Q} (proper quoting), and ${var@E} (expand escapes) are widely available.
- AI-assisted shell scripting — models like Claude are good at generating correct Bash; the skill is knowing how to verify what they output.
- GitHub Actions shells — most CI runs on Bash 5.x; scripts that work locally but fail in CI usually have quoting or
pipefail bugs.
The safe script template
Every non-trivial Bash script should start with this:
#!/usr/bin/env bash
set -euo pipefail
IFS=#39;\n\t'
# --- constants ---
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="${SCRIPT_DIR}/run.log"
# --- logging ---
log() { echo "[$(date +%T)] $*" | tee -a "$LOG_FILE"; }
err() { echo "[$(date +%T)] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
set -e exits on any command that returns non-zero. set -u errors on unset variables. set -o pipefail makes pipe failures propagate. Together they catch ~80% of common script bugs.
Quoting rules
name="Alice Smith"
# WRONG — breaks on spaces
echo Hello $name # echoes: Hello Alice Smith (ok here by luck)
cp $name /tmp/ # breaks: tries to copy "Alice" and "Smith" separately
# CORRECT — always quote
echo "Hello $name"
cp "$name" /tmp/
# Arrays for multiple files
files=("file one.txt" "file two.txt")
cp "${files[@]}" /tmp/ # correct — each element is a separate word
Conditionals with [[ ]]
# File tests
if [[ -f "$config_file" ]]; then
log "Found config: $config_file"
fi
# String comparison
if [[ "$environment" == "production" ]]; then
die "Do not run this script in production"
fi
# Regex match (no external grep needed)
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
log "Valid semver: $version"
fi
# Arithmetic
if (( count > 10 )); then
err "Too many items: $count"
fi
Functions with local variables
# Bad — global variables leak and collide
backup_files() {
timestamp=$(date +%Y%m%d) # global! pollutes outer scope
tar czf "backup-$timestamp.tar.gz" "$@"
}
# Good — local scope
backup_files() {
local timestamp
timestamp=$(date +%Y%m%d)
tar czf "backup-${timestamp}.tar.gz" "$@"
}
Argument parsing
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] <input>
Options:
-o, --output DIR Output directory (default: ./out)
-v, --verbose Verbose output
-h, --help Show this help
EOF
}
output="./out"
verbose=false
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
-v|--verbose) verbose=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "Unknown option: $1" ;;
*) break ;;
esac
done
input="${1:-}"
[[ -z "$input" ]] && die "input argument required"
Common patterns
Running a command and capturing output
# Capture stdout; fail if command fails
output=$(some_command 2>/dev/null) || die "some_command failed"
# Check exit code without set -e interference
if ! result=$(git status --porcelain 2>&1); then
die "git status failed: $result"
fi
Temporary files with cleanup
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT # always cleans up, even on error
curl -sS "$url" > "$tmpfile"
process "$tmpfile"
Comparing Bash vs alternatives
| Task |
Best tool |
| Glue commands together |
Bash |
| Text manipulation (lines) |
Bash + awk/sed |
| JSON processing |
jq or Python |
| HTTP requests |
curl + jq or Python httpx |
| CSV parsing |
Python csv module |
| Complex logic / data structures |
Python |
| Cross-platform automation |
Python or Node.js |
How to debug a Bash script
# Trace every command as it executes
set -x
# Or trace just a section
set -x
some_complex_function
set +x
# Dry-run mode (skip destructive commands)
DRY_RUN=${DRY_RUN:-false}
run() {
if [[ "$DRY_RUN" == "true" ]]; then
echo "[DRY RUN] $*"
else
"$@"
fi
}
run rm -rf /tmp/old-data
How to lint your scripts
# Install ShellCheck
brew install shellcheck # macOS
apt-get install shellcheck # Debian/Ubuntu
# Run on a script
shellcheck my-script.sh
# Run in CI (GitHub Actions)
- name: ShellCheck
uses: ludeeus/action-shellcheck@master
ShellCheck catches quoting errors, deprecated syntax, and subtle logic bugs. Run it on every script before committing.
Common mistakes
Not quoting command substitutions. result=$(cmd) is safe; using $result later unquoted is not. Quote both.
Using ls to iterate files. for f in $(ls *.txt) breaks on filenames with spaces. Use for f in *.txt (glob expansion is safe).
Ignoring set -e nuances. set -e does not trigger inside if conditions or && chains. This is by design, but surprises newcomers. Use explicit || die for critical commands.
Writing platform-specific Bash. date -v-7d is macOS BSD syntax. date -d '7 days ago' is GNU/Linux. For portable scripts, use Python's datetime instead.
What to skip
- Bash for JSON parsing —
grep/sed hacks on JSON break on whitespace, nesting, and Unicode. Use jq.
- Long Bash scripts over ~200 lines — maintainability collapses. Move to Python.
- Bash in Docker
CMD — prefer exec form (["node", "server.js"]) over shell form to get proper signal handling.
FAQ
What is the difference between sh and bash?
sh is the POSIX shell — a minimal spec. bash is a superset. #!/bin/sh on macOS runs zsh in POSIX mode; on Alpine it runs ash. Use #!/usr/bin/env bash when you need Bash features.
How do I source environment variables from a .env file?
export $(grep -v '^#' .env | xargs) works for simple cases. For files with spaces or special characters in values, use Python or the dotenv binary.
How do I run multiple commands in parallel in Bash?
Use & to background each command and wait to collect: cmd1 & cmd2 & wait. For more control (timeouts, exit codes), use GNU Parallel.
Should I use #!/bin/bash or #!/usr/bin/env bash?
Use #!/usr/bin/env bash — it finds bash in $PATH, which matters on macOS (system bash is v3; Homebrew installs v5 elsewhere).
Where to go next