Shell
General Principles
Section titled “General Principles”Write safe, maintainable, and portable shell scripts that handle errors gracefully and work across different environments.
When to Use Shell
Section titled “When to Use Shell”Good Use Cases
Section titled “Good Use Cases”Shell is appropriate for:
- System administration tasks
- CI/CD pipeline scripts
- Build and deployment automation
- File and process manipulation
- Wrapper scripts for other programs
When to Use Another Language
Section titled “When to Use Another Language”Consider Python, Node.js, or Go when:
- Complex data structures are needed
- Heavy string manipulation is required
- You need robust error handling
- The logic is becoming overly complex
- Cross-platform compatibility is critical
Note: There’s no hard line limit. Well-structured 200+ line shell scripts can be perfectly maintainable.
Shell Choice
Section titled “Shell Choice”Bash for Features
Section titled “Bash for Features”Use Bash when you need:
- Arrays and associative arrays
- Advanced string manipulation
- Process substitution
- Modern scripting features
#!/usr/bin/env bashPOSIX Shell for Portability
Section titled “POSIX Shell for Portability”Use POSIX shell (/bin/sh) when:
- Running in minimal environments (containers, embedded systems)
- Maximum portability is required
- The script is simple enough
#!/bin/shStrict Mode
Section titled “Strict Mode”Always Use Strict Mode
Section titled “Always Use Strict Mode”Start every script with strict mode:
#!/usr/bin/env bashset -euo pipefailIFS=$'\n\t'Explanation:
set -e- Exit on errorset -u- Exit on undefined variableset -o pipefail- Pipe failures cause exitIFS=$'\n\t'- Safer word splitting
Handling Expected Failures
Section titled “Handling Expected Failures”Use explicit handling for expected failures:
if ! command_that_might_fail; then echo "Command failed as expected"fi
# Orcommand_that_might_fail || trueScript Structure
Section titled “Script Structure”Header
Section titled “Header”Include a clear header:
#!/usr/bin/env bash## Description: Deploys application to production# Author: Your Name# Version: 1.0.0# Usage: ./deploy.sh [environment]#
set -euo pipefailMain Function Pattern
Section titled “Main Function Pattern”Use a main function for clarity:
#!/usr/bin/env bashset -euo pipefail
main() { local environment="${1:-staging}"
validate_environment "$environment" deploy_application "$environment" verify_deployment "$environment"}
validate_environment() { local env="$1" # validation logic}
deploy_application() { local env="$1" # deployment logic}
verify_deployment() { local env="$1" # verification logic}
main "$@"Error Handling
Section titled “Error Handling”Trap for Cleanup
Section titled “Trap for Cleanup”Always use trap for cleanup:
#!/usr/bin/env bashset -euo pipefail
cleanup() { local exit_code=$? # Cleanup actions rm -f "$temp_file" echo "Cleanup completed (exit: $exit_code)" exit "$exit_code"}
trap cleanup EXIT INT TERM
temp_file=$(mktemp)# Script logic hereError Messages
Section titled “Error Messages”Print errors to stderr:
error() { echo "ERROR: $*" >&2 exit 1}
warn() { echo "WARNING: $*" >&2}
info() { echo "INFO: $*"}
# Usage[[ -f "$config_file" ]] || error "Config file not found: $config_file"Variables
Section titled “Variables”Naming Conventions
Section titled “Naming Conventions”Use clear, descriptive names:
# Gooduser_name="john"config_file="/etc/app/config.yml"MAX_RETRIES=3
# Badun="john"cf="/etc/app/config.yml"mr=3Local Variables
Section titled “Local Variables”Always use local in functions:
process_user() { local user_name="$1" local user_id="$2"
# Function logic}Constants
Section titled “Constants”Use uppercase for constants:
readonly API_URL="https://api.example.com"readonly MAX_ATTEMPTS=5readonly CONFIG_DIR="/etc/myapp"Arrays
Section titled “Arrays”Use arrays for multiple related values:
# Indexed arraysfiles=("config.yml" "secrets.env" "database.conf")
for file in "${files[@]}"; do echo "Processing: $file"done
# Associative arrays (Bash 4+)declare -A config=( [host]="localhost" [port]="5432" [database]="myapp")
echo "Host: ${config[host]}"Quoting
Section titled “Quoting”Always Quote Variables
Section titled “Always Quote Variables”Quote all variables to prevent word splitting:
# Goodfile_name="my file.txt"cat "$file_name"
# Bad - will fail with spacescat $file_nameQuote Expansions
Section titled “Quote Expansions”# Goodfiles=("file1.txt" "file2.txt")for file in "${files[@]}"; do echo "$file"done
# Badfor file in ${files[@]}; do echo $filedoneWhen Not to Quote
Section titled “When Not to Quote”Don’t quote when you want word splitting:
# Intentional word splittingoptions="-v -x -f"command $options # Expands to: command -v -x -fConditionals
Section titled “Conditionals”Use [[ ]] Over [ ]
Section titled “Use [[ ]] Over [ ]”Prefer [[ ]] for conditionals:
# Good - modern syntaxif [[ "$status" == "active" ]]; then echo "Active"fi
if [[ -f "$file" && -r "$file" ]]; then echo "File exists and is readable"fi
# Old syntax (avoid in Bash)if [ "$status" = "active" ]; then echo "Active"fiString Comparisons
Section titled “String Comparisons”# Equality[[ "$var" == "value" ]]
# Pattern matching[[ "$file" == *.txt ]]
# Regular expressions[[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]File Tests
Section titled “File Tests”[[ -e "$file" ]] # exists[[ -f "$file" ]] # is regular file[[ -d "$dir" ]] # is directory[[ -r "$file" ]] # is readable[[ -w "$file" ]] # is writable[[ -x "$file" ]] # is executableFunctions
Section titled “Functions”Function Definition
Section titled “Function Definition”Use clear function syntax:
# Preferredfunction_name() { local arg1="$1" local arg2="${2:-default}"
# Function body}
# Also acceptablefunction function_name() { # Function body}Function Documentation
Section titled “Function Documentation”Document complex functions:
# Deploy application to specified environment# Arguments:# $1 - environment (staging|production)# $2 - version (optional, defaults to latest)# Returns:# 0 on success, 1 on failuredeploy() { local env="$1" local version="${2:-latest}"
# Deployment logic}Return Values
Section titled “Return Values”Use return codes and output:
# Return code indicates success/failurevalidate_input() { local input="$1"
if [[ -z "$input" ]]; then return 1 fi
return 0}
# Output result, check return codeget_user_id() { local username="$1"
# Query and output result echo "12345" return 0}
if user_id=$(get_user_id "john"); then echo "User ID: $user_id"fiFor Loops
Section titled “For Loops”# Iterate over arrayfor item in "${array[@]}"; do echo "$item"done
# Iterate over filesfor file in *.txt; do [[ -e "$file" ]] || continue # Skip if no matches echo "Processing: $file"done
# C-style loopfor ((i=0; i<10; i++)); do echo "$i"doneWhile Loops
Section titled “While Loops”# Read file line by linewhile IFS= read -r line; do echo "$line"done < "$file"
# Until conditioncounter=0until [[ $counter -ge 5 ]]; do echo "$counter" ((counter++))doneCommand Substitution
Section titled “Command Substitution”Use $() Over Backticks
Section titled “Use $() Over Backticks”# Goodcurrent_date=$(date +%Y-%m-%d)file_count=$(find . -type f | wc -l)
# Bad (outdated)current_date=`date +%Y-%m-%d`Formatting
Section titled “Formatting”Indentation
Section titled “Indentation”Use 2 spaces for indentation:
if [[ "$status" == "active" ]]; then for user in "${users[@]}"; do if [[ -n "$user" ]]; then process_user "$user" fi donefiLine Length
Section titled “Line Length”Keep lines under 100-120 characters:
# Goodecho "This is a reasonably long line that stays under the limit"
# For very long commands, break at logical pointsdocker run \ --name myapp \ --env DATABASE_URL="$db_url" \ --volume /data:/app/data \ myapp:latestSpacing
Section titled “Spacing”Use blank lines to separate logical sections:
# Variable declarationsreadonly CONFIG_FILE="/etc/app/config"readonly LOG_FILE="/var/log/app.log"
# Function definitionssetup() { # setup logic}
cleanup() { # cleanup logic}
# Main logicmain() { setup # main logic cleanup}Validation and Testing
Section titled “Validation and Testing”ShellCheck (Mandatory)
Section titled “ShellCheck (Mandatory)”Always run ShellCheck on your scripts:
shellcheck script.shDisable specific warnings with comments:
# shellcheck disable=SC2034unused_var="value" # Used by sourced scriptTesting with BATS
Section titled “Testing with BATS”Write tests for critical scripts:
#!/usr/bin/env bats
@test "script validates input" { run ./script.sh --invalid-option [ "$status" -eq 1 ]}
@test "script processes file correctly" { run ./script.sh test.txt [ "$status" -eq 0 ] [ "${lines[0]}" = "Success" ]}Best Practices
Section titled “Best Practices”✅ Use strict mode (set -euo pipefail)
✅ Quote all variables and expansions
✅ Use [[ ]] for conditionals
✅ Use local for function variables
✅ Handle errors explicitly with trap
✅ Run ShellCheck before committing
✅ Use meaningful variable names
✅ Document complex functions
✅ Use arrays for related values
✅ Print errors to stderr
Don’ts
Section titled “Don’ts”❌ Use backticks (use $() instead)
❌ Use [ ] in Bash (use [[ ]])
❌ Forget to quote variables
❌ Ignore ShellCheck warnings
❌ Use global variables in functions
❌ Parse ls output
❌ Use eval without sanitization
❌ Forget error handling
❌ Use cat unnecessarily (< file instead)
Common Patterns
Section titled “Common Patterns”Parse Command Arguments
Section titled “Parse Command Arguments”while [[ $# -gt 0 ]]; do case $1 in -e|--environment) environment="$2" shift 2 ;; -v|--verbose) verbose=true shift ;; -h|--help) show_help exit 0 ;; *) error "Unknown option: $1" ;; esacdoneRetry Logic
Section titled “Retry Logic”retry() { local max_attempts=$1 shift local cmd=("$@") local attempt=1
while ((attempt <= max_attempts)); do if "${cmd[@]}"; then return 0 fi
echo "Attempt $attempt failed, retrying..." >&2 ((attempt++)) sleep 2 done
return 1}
# Usageretry 3 curl https://api.example.com/healthLinting and Validation
Section titled “Linting and Validation”- ShellCheck - Static analysis (mandatory)
- shfmt - Shell script formatter
Testing
Section titled “Testing”- BATS - Bash Automated Testing System
- shunit2 - Unit testing framework
Debugging
Section titled “Debugging”# Trace executionbash -x script.sh
# Debug mode in scriptset -x # Enable debug mode# ... code to debugset +x # Disable debug mode