Skip to content

Shell

Write safe, maintainable, and portable shell scripts that handle errors gracefully and work across different environments.

Shell is appropriate for:

  • System administration tasks
  • CI/CD pipeline scripts
  • Build and deployment automation
  • File and process manipulation
  • Wrapper scripts for other programs

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.

Use Bash when you need:

  • Arrays and associative arrays
  • Advanced string manipulation
  • Process substitution
  • Modern scripting features
#!/usr/bin/env bash

Use POSIX shell (/bin/sh) when:

  • Running in minimal environments (containers, embedded systems)
  • Maximum portability is required
  • The script is simple enough
#!/bin/sh

Start every script with strict mode:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Explanation:

  • set -e - Exit on error
  • set -u - Exit on undefined variable
  • set -o pipefail - Pipe failures cause exit
  • IFS=$'\n\t' - Safer word splitting

Use explicit handling for expected failures:

Terminal window
if ! command_that_might_fail; then
echo "Command failed as expected"
fi
# Or
command_that_might_fail || true

Include a clear header:

deploy.sh
#!/usr/bin/env bash
#
# Description: Deploys application to production
# Author: Your Name
# Version: 1.0.0
# Usage: ./deploy.sh [environment]
#
set -euo pipefail

Use a main function for clarity:

#!/usr/bin/env bash
set -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 "$@"

Always use trap for cleanup:

#!/usr/bin/env bash
set -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 here

Print errors to stderr:

Terminal window
error() {
echo "ERROR: $*" >&2
exit 1
}
warn() {
echo "WARNING: $*" >&2
}
info() {
echo "INFO: $*"
}
# Usage
[[ -f "$config_file" ]] || error "Config file not found: $config_file"

Use clear, descriptive names:

Terminal window
# Good
user_name="john"
config_file="/etc/app/config.yml"
MAX_RETRIES=3
# Bad
un="john"
cf="/etc/app/config.yml"
mr=3

Always use local in functions:

Terminal window
process_user() {
local user_name="$1"
local user_id="$2"
# Function logic
}

Use uppercase for constants:

Terminal window
readonly API_URL="https://api.example.com"
readonly MAX_ATTEMPTS=5
readonly CONFIG_DIR="/etc/myapp"

Use arrays for multiple related values:

Terminal window
# Indexed arrays
files=("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]}"

Quote all variables to prevent word splitting:

Terminal window
# Good
file_name="my file.txt"
cat "$file_name"
# Bad - will fail with spaces
cat $file_name
Terminal window
# Good
files=("file1.txt" "file2.txt")
for file in "${files[@]}"; do
echo "$file"
done
# Bad
for file in ${files[@]}; do
echo $file
done

Don’t quote when you want word splitting:

Terminal window
# Intentional word splitting
options="-v -x -f"
command $options # Expands to: command -v -x -f

Prefer [[ ]] for conditionals:

Terminal window
# Good - modern syntax
if [[ "$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"
fi
Terminal window
# Equality
[[ "$var" == "value" ]]
# Pattern matching
[[ "$file" == *.txt ]]
# Regular expressions
[[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]
Terminal window
[[ -e "$file" ]] # exists
[[ -f "$file" ]] # is regular file
[[ -d "$dir" ]] # is directory
[[ -r "$file" ]] # is readable
[[ -w "$file" ]] # is writable
[[ -x "$file" ]] # is executable

Use clear function syntax:

Terminal window
# Preferred
function_name() {
local arg1="$1"
local arg2="${2:-default}"
# Function body
}
# Also acceptable
function function_name() {
# Function body
}

Document complex functions:

Terminal window
# Deploy application to specified environment
# Arguments:
# $1 - environment (staging|production)
# $2 - version (optional, defaults to latest)
# Returns:
# 0 on success, 1 on failure
deploy() {
local env="$1"
local version="${2:-latest}"
# Deployment logic
}

Use return codes and output:

Terminal window
# Return code indicates success/failure
validate_input() {
local input="$1"
if [[ -z "$input" ]]; then
return 1
fi
return 0
}
# Output result, check return code
get_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"
fi
Terminal window
# Iterate over array
for item in "${array[@]}"; do
echo "$item"
done
# Iterate over files
for file in *.txt; do
[[ -e "$file" ]] || continue # Skip if no matches
echo "Processing: $file"
done
# C-style loop
for ((i=0; i<10; i++)); do
echo "$i"
done
Terminal window
# Read file line by line
while IFS= read -r line; do
echo "$line"
done < "$file"
# Until condition
counter=0
until [[ $counter -ge 5 ]]; do
echo "$counter"
((counter++))
done
Terminal window
# Good
current_date=$(date +%Y-%m-%d)
file_count=$(find . -type f | wc -l)
# Bad (outdated)
current_date=`date +%Y-%m-%d`

Use 2 spaces for indentation:

Terminal window
if [[ "$status" == "active" ]]; then
for user in "${users[@]}"; do
if [[ -n "$user" ]]; then
process_user "$user"
fi
done
fi

Keep lines under 100-120 characters:

Terminal window
# Good
echo "This is a reasonably long line that stays under the limit"
# For very long commands, break at logical points
docker run \
--name myapp \
--env DATABASE_URL="$db_url" \
--volume /data:/app/data \
myapp:latest

Use blank lines to separate logical sections:

Terminal window
# Variable declarations
readonly CONFIG_FILE="/etc/app/config"
readonly LOG_FILE="/var/log/app.log"
# Function definitions
setup() {
# setup logic
}
cleanup() {
# cleanup logic
}
# Main logic
main() {
setup
# main logic
cleanup
}

Always run ShellCheck on your scripts:

Terminal window
shellcheck script.sh

Disable specific warnings with comments:

Terminal window
# shellcheck disable=SC2034
unused_var="value" # Used by sourced script

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" ]
}

✅ 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

❌ 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)

Terminal window
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"
;;
esac
done
Terminal window
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
}
# Usage
retry 3 curl https://api.example.com/health
  • ShellCheck - Static analysis (mandatory)
  • shfmt - Shell script formatter
  • BATS - Bash Automated Testing System
  • shunit2 - Unit testing framework
Terminal window
# Trace execution
bash -x script.sh
# Debug mode in script
set -x # Enable debug mode
# ... code to debug
set +x # Disable debug mode