Shell Script Development Best Practices
Shebang
#!/usr/bin/env bashfor portability.#!/bin/shfor POSIX-only scripts.- Executables: no extension or
.sh. Libraries: always.sh, not executable.
Error Handling
Always use set -Eeuo pipefail after the shebang:
-E: ERR trap inherited in functions-e: exit on failure-u: exit on undefined variables-o pipefail: exit if any pipeline command fails
Use || true only when explicitly ignoring failures.
Formatting
- 2-space indent, never tabs. Max 80 characters per line.
- Place
; thenand; doon the same line asif/for/while. - Split long pipelines across lines with
|at the start of continuation lines.
Variables
- Always quote:
"$variable","${prefix}_${suffix}". readonlyordeclare -rfor constants.UPPER_CASEfor constants/environment variables.localfor function variables.lower_casefor local/function-scoped variables.- Avoid globals; prefer function parameters and return values.
Functions
- Define before use.
localfor all variables. Return meaningful exit codes. lower_casewith underscores. Use::for library namespacing (mylib::helper).- Document purpose, globals, arguments, outputs, and return values:
# Downloads and verifies a file # Globals: DOWNLOAD_TIMEOUT # Args: $1=URL, $2=destination # Outputs: writes to stdout on success # Returns: 0 on success, 1 on failure download_file() { local url="${1:?URL required}" local dest="${2:?destination required}" } - Use a
mainfunction in scripts with multiple functions; call it last:main() { parse_args "$@" run } main "$@"
Safe file iteration
while IFS= read -r -d '' file; do
process "$file"
done < <(find . -name "*.txt" -print0)
Cleanup with trap
cleanup() {
local exit_code=$?
# Kill background jobs before cleaning up
jobs -p | xargs -r kill 2>/dev/null || true
wait 2>/dev/null || true
rm -rf "$TEMP_DIR"
exit "$exit_code"
}
trap cleanup EXIT
trap 'trap - EXIT; cleanup; kill -INT $$' INT
trap 'trap - EXIT; cleanup; kill -TERM $$' TERM
trap cleanup EXITruns on normal exit, errors (set -e), and signals.- Separate
INT/TERMtraps re-raise the signal so the parent process sees the correct exit status. - Kill child processes explicitly — by default, backgrounded jobs survive the parent's exit.
Logging
log_info() { printf '[INFO] %s\n' "$*"; }
log_warn() { printf '[WARN] %s\n' "$*" >&2; }
log_error() { printf '[ERROR] %s\n' "$*" >&2; }
die() { log_error "$@"; exit 1; }
Input/Output
printf '%s\n' "$msg"overechofor formatted output.- Errors to stderr:
>&2. - Use heredocs for multi-line strings.
Arithmetic
- Use
(( ))or$(( ))for math. Neverlet,$[ ], orexpr.
String Manipulation
Prefer parameter expansion over sed/awk for simple transformations (${var#pattern}, ${var%pattern}, ${var/old/new}).
Defaults and guards:
"${1:-default}" # use default if $1 is unset or empty
"${1:?error msg}" # exit with error if $1 is unset or empty
"${var:+alt}" # use alt if var is set and non-empty, else empty
Working with JSON (jq)
Use jq for JSON processing — never parse JSON with grep/sed/awk.
# Extract a field
name=$(jq -r '.user.name' response.json)
# Iterate over array elements
jq -r '.items[] | .id' data.json | while IFS= read -r id; do
process "$id"
done
# Build JSON safely with --arg (handles escaping)
jq -n --arg name "$USER" --arg host "$HOSTNAME" \
'{"name": $name, "host": $host}'
# Modify JSON in-place
jq '.config.timeout = 30 | .config.retries = 3' config.json > tmp.json \
&& mv tmp.json config.json
# Select and filter
jq '[.[] | select(.status == "active")]' users.json
- Always use
-r(raw output) when pipingjqoutput to other commands — without it, strings include quotes. - Use
--argand--argjsonto pass shell variables into jq expressions safely. - Use
--exit-status(or-e) to make jq return non-zero when the filter producesfalseornull.
Arrays
# Indexed arrays
files=("one.txt" "two.txt" "three.txt")
files+=("four.txt") # append
echo "${files[0]}" # first element
echo "${files[@]}" # all elements (preserves quoting)
echo "${#files[@]}" # length
# Iterate safely (handles spaces in elements)
for f in "${files[@]}"; do
process "$f"
done
# Associative arrays (bash 4+)
declare -A config
config[host]="localhost"
config[port]="5432"
for key in "${!config[@]}"; do
echo "$key=${config[$key]}"
done
Security
- Validate all user inputs. Use
--to separate options from arguments. - Avoid
eval. Secure permissions (chmod 600) for sensitive files. - SUID/SGID forbidden on shell scripts — use
sudoinstead. - Use
mktempfor temp files — never hardcode/tmp/myfile:TEMP_DIR="$(mktemp -d)" || die "failed to create temp dir"
ShellCheck
Run shellcheck on every script. Fix all warnings — don't suppress without justification.
# Suppress a specific rule with an explanation
# shellcheck disable=SC2086 # word splitting intended for $flags
docker run $flags "$image"
# Suppress for an entire file (top of file, after shebang)
# shellcheck disable=SC2034 # variables sourced by another script
# Source directive for files shellcheck can't find
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
Rule ranges: SC1xxx = syntax/parsing, SC2xxx = best practices, SC3xxx = POSIX compatibility.
Portability
- Check required tools:
command -v git >/dev/null || die "git required". - Avoid GNU-specific options when possible (see portability-matrix.md).
- POSIX sh vs bash: POSIX sh has no arrays, no
[[ ]], no<()process substitution, no{a,b}brace expansion, no+=append. Use#!/bin/shonly when you avoid all bash-isms. - macOS ships bash 3.2 (2007, GPLv2). Associative arrays (
declare -A) require bash 4+. If targeting macOS, either use#!/bin/sh(POSIX) or require users to install modern bash via Homebrew.
New script workflow
- [ ] Start with shebang and set -Eeuo pipefail
- [ ] Add argument parsing with usage/help
- [ ] Add prerequisite checks (required tools)
- [ ] Implement main logic with cleanup trap
- [ ] Add logging and error handling
- [ ] Run validation loop (below)
Validation loop
shellcheck script.sh— fix all warnings (SC1xxx=syntax, SC2xxx=best practices, SC3xxx=POSIX)bash -n script.sh— fix syntax errors- Test with valid input — verify expected behavior
- Test with invalid input — verify error messages and exit codes
- Test on macOS if targeting both platforms
- Repeat until shellcheck is clean and all cases pass
Deep-dive references
CLI tool template: See patterns/cli-tool-template.md for a production-ready script template Deployment scripts: See patterns/deployment-scripts.md for deploy, rollback, health check patterns Testing: See patterns/testing-patterns.md for bats framework, mocking, CI integration GNU vs BSD: See portability-matrix.md for cross-platform command differences
Official references
- Google Shell Style Guide — naming, formatting, features, best practices
- ShellCheck Wiki — rule-by-rule documentation (SC1xxx syntax, SC2xxx quality, SC3xxx POSIX)
