| name | shell-scripting |
| description | Practical bash scripting guidance emphasising defensive programming, ShellCheck compliance, and simplicity. Use when writing shell scripts that need to be reliable and maintainable. |
Bash Scripting Best Practices
Guidance for writing reliable, maintainable bash scripts following modern best practices. Emphasises simplicity, automated tooling, and defensive programming without over-engineering.
When to Use Shell (and When Not To)
Use Shell For:
- Small utilities and simple wrapper scripts (<100 lines)
- Orchestrating other programmes and tools
- Simple automation tasks
- Build/deployment scripts with straightforward logic
- Quick data transformation pipelines
Do NOT Use Shell For:
- Complex business logic or data structures
- Performance-critical code
- Scripts requiring extensive error handling
- Anything over ~100 lines or with non-straightforward control flow
- When you need proper data structures beyond arrays
Critical: If your script grows too large (1000+ lines) or complex, consider offering to rewrite it in a proper language (Python, Go, etc.) before it becomes unmaintainable.
Mandatory Foundations
Every bash script must have these elements:
1. Proper Shebang
#!/usr/bin/env bash
Why: Portable across systems where bash may not be at /bin/bash (e.g., macOS, BSD, NixOS).
Alternative: #!/bin/bash if you know the script only runs on Linux and prefer explicit paths.
2. Strict Mode
set -euo pipefail
What each flag does:
-e: Exit immediately if any command fails (non-zero exit)
-u: Treat unset variables as errors
-o pipefail: Pipe fails if ANY command in pipeline fails (not just the last)
When to add -x: Only for debugging, not in production scripts (makes output noisy).
3. ShellCheck Compliance
Run ShellCheck on EVERY script before committing:
shellcheck script.sh
Fix all warnings. ShellCheck catches:
- Unquoted variables
- Deprecated syntax
- Common bugs and pitfalls
- Portability issues
4. Basic Script Structure
#!/usr/bin/env bash
set -euo pipefail
die() {
echo "Error: ${1}" >&2
exit 1
}
Core Safety Patterns
Always Quote Variables
Why: Prevents word splitting and globbing disasters.
cp $source $destination
rm -rf $prefix/bin
cp "${source}" "${destination}"
rm -rf "${prefix}/bin"
echo "${var}"
echo "$var"
echo $var
Check Required Variables
: "${REQUIRED_VAR:?REQUIRED_VAR must be set}"
: "${DATABASE_URL:?DATABASE_URL is required. Set it in .env}"
Validate Inputs
[[ -f "${config_file}" ]] || die "Config file not found: ${config_file}"
command -v jq >/dev/null 2>&1 || die "jq is required but not installed"
[[ -d "${target_dir}" ]] || die "Directory does not exist: ${target_dir}"
Essential Patterns
Pattern 1: Simple Script Template
Use this for straightforward scripts:
#!/usr/bin/env bash
set -euo pipefail
die() {
echo "Error: ${1}" >&2
exit 1
}
command -v jq >/dev/null 2>&1 || die "jq required"
[[ $# -eq 1 ]] || die "Usage: ${0} <logfile>"
logfile="${1}"
[[ -f "${logfile}" ]] || die "File not found: ${logfile}"
grep ERROR "${logfile}" | jq -r '.message'
Pattern 2: Cleanup on Exit
Use trap for guaranteed cleanup:
#!/usr/bin/env bash
set -euo pipefail
tmpdir=$(mktemp -d)
trap 'rm -rf "${tmpdir}"' EXIT
echo "Working in: ${tmpdir}"
Pattern 3: Safe Function Definition
Functions should be simple and focused:
check_dependency() {
local cmd="${1}"
command -v "${cmd}" >/dev/null 2>&1 || die "${cmd} not installed"
}
process_file() {
local file="${1}"
local output="${2}"
[[ -f "${file}" ]] || die "Input file missing: ${file}"
sed 's/foo/bar/g' "${file}" > "${output}"
}
Important: Declare and set variables from command substitution separately to catch errors:
local result="$(failing_command)"
local result
result="$(failing_command)"
Pattern 4: Safe Array Handling
Arrays are useful for handling lists with spaces:
declare -a files=("file one.txt" "file two.txt" "file three.txt")
for file in "${files[@]}"; do
echo "Processing: ${file}"
done
declare -a flags=(--verbose --output "${output_file}")
mycommand "${flags[@]}" "${input}"
mapfile -t lines < <(grep pattern "${file}")
Pattern 5: Conditional Testing
Use [[ ]] for bash (safer and more features):
[[ -f "${file}" ]]
[[ -d "${dir}" ]]
[[ -r "${file}" ]]
[[ -w "${file}" ]]
[[ -x "${binary}" ]]
[[ -z "${var}" ]]
[[ -n "${var}" ]]
[[ "${a}" == "${b}" ]]
(( count > 0 ))
(( total >= minimum ))
[[ -f "${file}" && -r "${file}" ]] || die "File not readable: ${file}"
Pattern 6: Simple Argument Handling
For simple scripts, prefer positional arguments:
#!/usr/bin/env bash
set -euo pipefail
[[ $# -eq 2 ]] || die "Usage: ${0} <source> <dest>"
source="${1}"
dest="${2}"
[[ -f "${source}" ]] || die "Source not found: ${source}"
For scripts needing flags, keep it simple:
VERBOSE="${VERBOSE:-false}"
DRY_RUN="${DRY_RUN:-false}"
Pattern 7: Process Substitution Over Temp Files
Avoid creating temporary files when possible:
first_command > /tmp/output.txt
second_command < /tmp/output.txt
rm /tmp/output.txt
second_command <(first_command)
diff <(sort file1.txt) <(sort file2.txt)
Pattern 8: Prefer Builtins Over External Commands
Builtins are faster and more reliable:
filename="${path##*/}"
dirname="${path%/*}"
extension="${filename##*.}"
name="${filename%.*}"
count=$(( count + 1 ))
[[ -f "${file}" ]]
length="${#string}"
Intermediate Patterns
Pattern 9: Structured Logging
Keep logging simple and consistent:
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ${1}" >&2
}
error() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: ${1}" >&2
}
log "Starting process"
error "Failed to connect to database"
Pattern 10: Main Function Pattern
For longer scripts (50+ lines), use a main function:
#!/usr/bin/env bash
set -euo pipefail
setup() {
command -v jq >/dev/null 2>&1 || die "jq required"
}
process() {
log "Processing data"
}
cleanup() {
log "Cleanup complete"
}
main() {
setup
process
cleanup
}
main "${@}"
Pattern 11: Idempotent Operations
Scripts should be safe to run multiple times:
if [[ ! -d "${target_dir}" ]]; then
mkdir -p "${target_dir}"
fi
if [[ ! -f "${config_file}" ]]; then
echo "DEFAULT_VALUE=true" > "${config_file}"
fi
mv "${source}" "${dest}"
Pattern 12: Safe While Loop Reading
Don't pipe to while (creates subshell):
count=0
cat file.txt | while read -r line; do
(( count++ ))
done
echo "${count}"
count=0
while read -r line; do
(( count++ ))
done < <(cat file.txt)
echo "${count}"
mapfile -t lines <file.txt
count="${#lines[@]}"
Style Guidelines
Formatting
- Indentation: 2 spaces, never tabs
- Line length: Maximum 120 characters
- Long strings: Use here-documents or embedded newlines
docker run \
--name my-container \
--volume "${PWD}:/data" \
--env "FOO=bar" \
my-image:latest
cat <<EOF
This is a long message
that spans multiple lines
and is more readable this way.
EOF
Naming Conventions
check_dependencies() { ... }
process_files() { ... }
local input_file="${1}"
local line_count=0
readonly MAX_RETRIES=3
readonly CONFIG_DIR="/etc/myapp"
File Extensions
- Executables:
.sh extension OR no extension (prefer no extension for user-facing commands)
- Libraries: Always
.sh extension and NOT executable
Function Documentation
Document functions that aren't obvious:
check_file_exists() {
[[ -f "${1}" ]]
}
process_logs() {
local logfile="${1}"
local output_dir="${2}"
}
What to Avoid
Don't Use These
output=`command`
output=$(command)
eval "${user_input}"
result=$(expr 5 + 3)
result=$(( 5 + 3 ))
[ -f "${file}" ]
[[ -f "${file}" ]]
result=$[5 + 3]
result=$(( 5 + 3 ))
function foo() { ... }
foo() { ... }
Anti-Patterns
rm ${files}
rm "${files}"
for file in $(ls); do
for file in *; do
yes | risky-command
make build
make build || die "Build failed"
Complexity Warning Signs
If your script has any of these, consider rewriting in Python/Go:
- More than 100 lines
- Complex data structures beyond simple arrays
- Nested loops over arrays of arrays
- Heavy string manipulation logic
- Complex state management
- Mathematical calculations beyond basic arithmetic
- Need for unit testing individual functions
- JSON/YAML parsing beyond simple jq queries
Advanced: Dry-Run Pattern
For scripts that modify things:
DRY_RUN="${DRY_RUN:-false}"
run() {
if [[ "${DRY_RUN}" == "true" ]]; then
echo "[DRY RUN] ${*}" >&2
return 0
fi
"${@}"
}
run cp "${source}" "${dest}"
run rm -f "${old_file}"
Quick Reference Checklist
Before considering a bash script complete:
Summary
- Start simple: Don't over-engineer. Most scripts should be <50 lines.
- Use ShellCheck: It catches most problems automatically.
- Quote everything:
"${var}" not $var.
- Fail fast:
set -euo pipefail and validate inputs.
- Know when to stop: If it's getting complex, use a real language.
- Compose don't complicate: Use pipes and process substitution.
- Be idempotent: Scripts should be safe to run multiple times.
- Test error paths: Make sure your script fails safely.
Remember: Shell scripts are for gluing things together, not building complex logic. Keep them simple, safe, and focused.