| name | Shell Scripting |
| description | Master shell scripting best practices, error handling, portability, debugging, and performance optimization for reliable automation scripts |
| metadata | {"author":"cosmicstack-labs","version":"1.0.0","category":"automation","tags":["shell-scripting","bash","posix","error-handling","debugging","portability","automation"]} |
Shell Scripting
Core Principles
1. Fail Explicitly
A script that encounters an error should stop, not continue with corrupted state. Use defensive coding: validate assumptions, check exit codes, and never assume success.
2. Clarity Over Cleverness
Shell scripting is already cryptic enough. Write scripts that are easy to read, not impressive one-liners. Your future self will thank you.
3. Portability By Default
Unless you have a specific reason to require Bash 4+, write for POSIX sh. Your script may need to run in a container, an embedded system, or a legacy environment.
4. Defensive Safety
Every variable could be empty. Every command could fail. Every file could be missing. Write scripts that survive these realities.
5. Principle of Least Surprise
Scripts should behave predictably. Use consistent exit codes, clear error messages, and help text. No silent failures, no hidden side effects.
Scripting Maturity Model
| Level | Name | Description |
|---|
| 0 | Ad-hoc | One-off commands saved to a file. No error handling. Only the author understands it. |
| 1 | Functional | Has shebang and basic structure. Handles common success paths. Brittle. |
| 2 | Defensive | Uses set -euo pipefail, checks exit codes, validates arguments. Has basic error messages. |
| 3 | Robust | Uses functions, has help text, proper argument parsing with defaults. Handles cleanup with traps. Portable across environments. |
| 4 | Production | Comprehensive error handling, logging, structured output, CI-tested with shellcheck. Configuration via environment or config files. |
| 5 | Battle-tested | Unit-tested, documented, versioned, handles edge cases (race conditions, signals, resource limits). Used in critical production pipelines. |
Target at least Level 3 for any script that runs unattended.
Script Structure
Shebang and Set Flags
Every script begins with a shebang and safety flags:
#!/usr/bin/env bash
set -euo pipefail
Note: set -e has edge cases — it won't catch failures in conditionals or in the left side of &&/||. Don't rely on it exclusively; also check exit codes explicitly where it matters.
Functions
Organize logic into reusable, named functions:
log_info() { echo "[INFO] $*" >&2; }
log_warn() { echo "[WARN] $*" >&2; }
log_error() { echo "[ERROR] $*" >&2; }
validate_environment() {
local env="${1:-}"
case "$env" in
staging|production) return 0 ;;
*) log_error "Invalid environment: $env"; return 1 ;;
esac
}
build_application() {
log_info "Starting build..."
npm run build || return 1
log_info "Build completed successfully"
}
deploy_application() {
local env="$1"
log_info "Deploying to $env..."
}
Function Rules:
- Use
local for all variables inside functions to avoid global scope pollution
- Return meaningful exit codes (0 = success, 1 = general error, 2 = usage error)
- Name functions with verbs (validate_, build_, deploy_, cleanup_)
- Keep functions focused — one function, one responsibility
main() Guard
Always use a main function with a guard to control execution:
main() {
local env="staging"
local skip_tests=false
while [[ $# -gt 0 ]]; do
case "$1" in
--env) env="$2"; shift 2 ;;
--skip-tests) skip_tests=true; shift ;;
--help) show_help; exit 0 ;;
*) log_error "Unknown option: $1"; show_help; exit 2 ;;
esac
done
validate_environment "$env" || exit 1
build_application || exit 1
deploy_application "$env" || exit 1
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
The main guard allows the script to be sourced (for testing individual functions) without executing the main flow.
Exit Codes
Standard Unix exit codes:
| Code | Meaning | When to Use |
|---|
| 0 | Success | Everything worked perfectly |
| 1 | General error | Catch-all for failures |
| 2 | Misuse of shell builtins | Invalid options, wrong arguments |
| 64 | Command line usage error | Missing required argument |
| 65 | Data format error | Input data is malformed |
| 69 | Service unavailable | Required service/dependency missing |
| 70 | Internal software error | Bug, unexpected state |
| 77 | Permission denied | Insufficient permissions |
| 126 | Command invoked cannot execute | Permission issue on called program |
| 127 | Command not found | Missing dependency |
| 130 | Script terminated by Ctrl+C | SIGINT received |
Best practice: Use exit codes consistently. exit 1 for generic errors, more specific codes where useful.
Error Handling
set -euo pipefail (The Holy Trinity)
set -euo pipefail
Caveat: set -e is disabled inside conditionals (if, while, until) and on the left side of || or &&. This is intentional — it allows you to test command success. But be aware of it:
if command_might_fail; then
log_info "Command succeeded"
fi
command_might_fail || log_warn "Command failed, but continuing..."
trap for Cleanup
Use trap to guarantee cleanup when a script exits — whether normally, by error, or by signal:
CLEANUP_FILES=()
cleanup() {
local exit_code=$?
log_info "Cleaning up..."
for file in "${CLEANUP_FILES[@]}"; do
[[ -f "$file" ]] && rm -f "$file"
done
exit "$exit_code"
}
trap cleanup EXIT
trap 'exit' INT TERM
create_temp_file() {
local tmp
tmp=$(mktemp) || exit 1
CLEANUP_FILES+=("$tmp")
echo "$tmp"
}
Multiple trap patterns:
trap 'cleanup' EXIT
trap 'exit 1' INT
trap 'exit 1' TERM
trap 'emergency_cleanup; exit 1' ERR
emergency_cleanup() {
log_error "Emergency cleanup triggered"
}
Error Functions
Dedicated error handling improves clarity:
fatal() {
log_error "$*"
exit 1
}
warn() {
log_warn "$*"
return 1
}
assert() {
local condition="$1"
local message="${2:-Assertion failed}"
if ! eval "$condition"; then
fatal "$message"
fi
}
assert '[[ -f "$CONFIG_FILE" ]]' "Config file not found: $CONFIG_FILE"
assert '[[ -n "${AWS_REGION:-}" ]]' "AWS_REGION is not set"
Portability
Bash vs POSIX sh
| Feature | Bash | POSIX sh | Portable Alternative |
|---|
| Arrays | arr=(a b c) | Not supported | Use space-separated strings + for i in $list |
| Associative arrays | declare -A map | Not supported | Avoid or use external files |
| [[ ]] test | [[ "$a" == "$b" ]] | ["$a" = "$b"] | Use [ ] for POSIX |
| Here strings | grep <<< "$var" | Not supported | `echo "$var" |
| ${var^} (case mod) | echo "${var^}" | Not supported | tr '[:lower:]' '[:upper:]' |
| Process substitution | diff <(cmd1) <(cmd2) | Not supported | Use temp files |
| let / (( )) | (( x++ )) | Not supported | x=$(( x + 1 )) |
Rule of thumb: Start with #!/bin/sh unless you genuinely need Bash-specific features. If you need arrays or associative maps, use Bash but document the requirement.
OS Differences
detect_os() {
case "$(uname -s)" in
Darwin) echo "macos" ;;
Linux) echo "linux" ;;
CYGWIN*|MINGW*|MSYS*) echo "windows" ;;
*) echo "unknown" ;;
esac
}
OS=$(detect_os)
sed_in_place() {
local file="$1"
local pattern="$2"
if [[ "$OS" == "macos" ]]; then
sed -i '' "$pattern" "$file"
else
sed -i "$pattern" "$file"
fi
}
format_timestamp() {
local ts="$1"
if [[ "" == ]];
-r -u
-d -u
}
Checking for Command Availability
command_exists() {
command -v "$1" >/dev/null 2>&1
}
check_dependencies() {
local missing=()
for cmd in "$@"; do
if ! command_exists "$cmd"; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
fatal "Missing required commands: ${missing[*]}"
fi
}
check_dependencies "jq" "curl" "aws" "docker"
if command_exists "jq"; then
JSON_FMT="jq"
else
log_warn "jq not found, falling back to grep-based parsing (fragile)"
JSON_FMT="grep"
fi
Best Practices
Quoting Variables
file_path=$HOME/dir/$filename
if [ $status = "ok" ]; then
file_path="$HOME/dir/$filename"
if [ "$status" = "ok" ]; then
if [[ "$name" == "$pattern" ]]; then
Golden Rule: If a variable contains user input, a filename, or any path, quote it. When in doubt, quote it. Unquoted variables are a leading cause of shell script bugs.
Using [[ ]] Over [ ]
if [ "$var" = "value" ] && [ -f "$file" ]; then
if [[ "$var" == "value" && -f "$file" ]]; then
Avoiding ls Parsing
for file in $(ls *.txt); do
process "$file"
done
for file in *.txt; do
[[ -f "$file" ]] && process "$file"
done
find /path -name "*.txt" -type f -print0 | while IFS= read -r -d '' file; do
process "$file"
done
Temporary File Handling
tempfile="/tmp/myscript.tmp"
echo "$data" > "$tempfile"
create_temp_dir() {
local tmpdir
tmpdir=$(mktemp -d) || fatal "Failed to create temp directory"
CLEANUP_FILES+=("$tmpdir")
echo "$tmpdir"
}
TMPDIR=$(create_temp_dir)
TMPFILE="$TMPDIR/data.txt"
Argument Parsing
Using getopts
getopts is the POSIX-compliant way to parse short options:
#!/usr/bin/env bash
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] <input-file>
Options:
-e <env> Target environment (staging|production)
-v Verbose output
-n Dry run (no actual changes)
-h Show this help message
Examples:
$(basename "$0") -e staging data.csv
$(basename "$0") -v -n data.csv
EOF
exit 0
}
env="staging"
verbose=false
dry_run=false
while getopts "e:vn h" opt; do
case "$opt" in
e) env="$OPTARG" ;;
v) verbose=true ;;
n) dry_run=true ;;
h) usage ;;
?) echo "Invalid option: -$OPTARG" >&2; usage; exit 2 ;;
esac
done
shift $((OPTIND - 1))
if [[ $# -lt 1 ]]; then
echo "Error: Missing input file" >&2
usage
exit 2
fi
INPUT_FILE="$1"
Manual Parsing with shift (for long options)
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--env|-e)
env="${2:?Error: --env requires an argument}"
shift 2
;;
--verbose|-v)
verbose=true
shift
;;
--dry-run|-n)
dry_run=true
shift
;;
--config|-c)
config_file="${2:?Error: --config requires an argument}"
shift 2
;;
--help|-h)
show_help
exit 0
;;
--)
shift
positional+=("$@")
break
;;
-*)
log_error "Unknown option: $1"
show_help
exit 2
;;
*)
positional+=("$1")
shift
;;
esac
done
}
Help Text Best Practices
show_help() {
cat <<EOF
${SCRIPT_NAME:-$(basename "$0")} — ${SCRIPT_DESCRIPTION:-"No description"}
USAGE:
$(basename "$0") [OPTIONS] <input> [<input>...]
OPTIONS:
-e, --env ENV Target environment (default: staging)
-v, --verbose Enable verbose output
-n, --dry-run Show what would be done without doing it
-c, --config FILE Path to config file
-h, --help Show this help message
ARGUMENTS:
<input> One or more input files or directories
EXAMPLES:
$(basename "$0") -e production data.csv
$(basename "$0") --verbose --dry-run ./config.json
EXIT CODES:
0 Success
1 General error
2 Usage error (invalid options or arguments)
EOF
}
Debugging
set -x (Execution Trace)
debug_section() {
set -x
set +x
}
if [[ "$verbose" == "true" ]]; then
set -x
fi
shellcheck
ShellCheck is the single most important tool for writing safe shell scripts:
shellcheck script.sh
Common ShellCheck warnings and fixes:
| SC# | Warning | Fix |
|---|
| SC2086 | Double quote to prevent globbing | "$var" instead of $var |
| SC2002 | Useless cat | < file cmd instead of `cat file |
| SC2046 | Quote this to prevent word splitting | "$(command)" instead of $(command) |
| SC2164 | Use cd ... | |
| SC2068 | Double quote array expansions | "${arr[@]}" instead of ${arr[@]} |
| SC2155 | Declare and assign separately | Declare var, then assign on next line |
bash -n Syntax Checking
bash -n script.sh
ci_check() {
local errors=0
for script in scripts/*.sh; do
if ! bash -n "$script"; then
log_error "Syntax error in: $script"
((errors++))
fi
done
return "$errors"
}
Runtime Debugging Techniques
debug() {
[[ "$verbose" == "true" ]] && echo "[DEBUG] $*" >&2
}
debug "VARIABLES: env=$env, file=$INPUT_FILE, mode=$mode"
trace() {
echo "[$(date '+%H:%M:%S.%3N')] $*" >&2
}
trace "Starting deployment to $env"
error_trap() {
local line=$1
local command=$2
local code=$3
log_error "Error on line $line: '$command' exited with code $code"
}
trap 'error_trap $LINENO "$BASH_COMMAND" $?' ERR
Performance
Avoiding Subshells
result=$(cat file.txt | grep "pattern" | head -1)
result=$(grep "pattern" file.txt | head -1)
while IFS= read -r line; do
[[ "$line" == *"pattern"* ]] && { result="$line"; break; }
done < file.txt
Minimizing Pipes
cat data.log | grep "ERROR" | cut -d' ' -f2 | sort | uniq
awk '/ERROR/ {print $2}' data.log | sort -u
Using Built-ins Over External Commands
[ "$(echo "$var" | tr '[:upper:]' '[:lower:]')" = "yes" ]
[[ "${var,,}" == "yes" ]]
if echo "$line" | grep -q "pattern"; then
if [[ "$line" == *"pattern"* ]]; then
Bulk Operations
for file in *.txt; do
mv "$file" "${file%.txt}.md"
done
rename 's/\.txt$/.md/' *.txt
find . -name "*.txt" -exec sh -c 'mv "$1" "${1%.txt}.md"' _ {} \;
Common Mistakes
1. Forgetting to Quote Variables
if [ $status = ok ]; then
if [[ "$status" == "ok" ]]; then
2. Missing Error Handling on cd
cd /some/directory
rm -rf ./*
cd /some/directory || fatal "Failed to change to /some/directory"
3. Unsafe Temporary Files
echo "$data" > /tmp/output.txt
tmpfile=$(mktemp) || fatal "Failed to create temp file"
trap 'rm -f "$tmpfile"' EXIT
echo "$data" > "$tmpfile"
4. Parsing ls Output
for file in $(ls *.txt); do
for file in *.txt; do
[[ -f "$file" ]] || continue
process "$file"
done
5. Not Using set -euo pipefail
echo "Starting..."
some_command_that_fails
echo "Done!"
set -euo pipefail
6. Useless Use of cat
cat file.txt | grep "pattern"
grep "pattern" file.txt
< file.txt grep "pattern"
7. Forgetting to Handle Errors in Pipelines
cmd_that_fails | cmd_that_succeeds
set -o pipefail
if ! cmd_that_fails | cmd_that_succeeds; then
log_error "Pipeline failed"
fi
8. Incorrect String Comparisons
if [ "$var" -eq 10 ]; then
if ["$var" = "value"]; then
if [[ "$var" == "value" ]]; then
if [ "$var" = "value" ]; then
9. Zeroing In on the Wrong Problem
eval "echo \$$var"
echo "${!var}"
10. No Help or Usage Text
11. Modifying IFS Without Saving/Restoring
IFS=',' read -ra fields <<< "$csv_line"
old_ifs="$IFS"
IFS=','
read -ra fields <<< "$csv_line"
IFS="$old_ifs"
12. Not Using printf for Reliable Output
echo "Hello\nWorld"
printf 'Hello\nWorld\n'
printf '%s\n' "$var"