Shell/Bash Guide¶
Applies to: Bash 4+, POSIX sh, Zsh, Shell Scripts, CI/CD Pipelines
Core Principles¶
- Fail Fast: Use strict mode, check errors
- Portability: Prefer POSIX when possible
- Security: Quote variables, validate input
- Readability: Clear naming, comments, functions
- Idempotency: Scripts should be safe to re-run
Language-Specific Guardrails¶
Strict Mode (Always Use)¶
- ✓ Start scripts with
set -euo pipefail - ✓ Use
#!/usr/bin/env bashfor portability - ✓ Check return codes for critical commands
- ✓ Use
trapfor cleanup on exit
Quoting¶
- ✓ Always quote variables:
"$variable" - ✓ Use
"${variable}"in strings - ✓ Quote command substitutions:
"$(command)" - ✓ Use arrays for lists, not space-separated strings
Style¶
- ✓ Use snake_case for variables and functions
- ✓ Use SCREAMING_SNAKE_CASE for constants/env vars
- ✓ 2-space indentation
- ✓ Max line length: 80-100 characters
- ✓ Use
[[instead of[in Bash - ✓ Use
(( ))for arithmetic
Functions¶
- ✓ Declare functions with
function_name() { } - ✓ Use
localfor function variables - ✓ Document with comments above function
- ✓ Return meaningful exit codes
Security¶
- ✓ Validate all user input
- ✓ Don't use
evalunless absolutely necessary - ✓ Quote all variables (prevents word splitting)
- ✓ Use full paths for commands in cron/scripts
- ✓ Don't store secrets in scripts
Script Template¶
#!/usr/bin/env bash
#
# Description: Brief description of what this script does
# Usage: ./script.sh [options] <arguments>
#
# Author: Your Name
# Date: 2024-01-01
set -euo pipefail
IFS=$'\n\t'
# Constants
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
# Default values
DEBUG="${DEBUG:-false}"
VERBOSE="${VERBOSE:-false}"
# Colors (optional)
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[0;33m'
readonly NC='\033[0m' # No Color
#######################################
# Print error message to stderr
# Arguments:
# Message to print
#######################################
error() {
echo -e "${RED}ERROR: $*${NC}" >&2
}
#######################################
# Print info message
# Arguments:
# Message to print
#######################################
info() {
echo -e "${GREEN}INFO: $*${NC}"
}
#######################################
# Print warning message
# Arguments:
# Message to print
#######################################
warn() {
echo -e "${YELLOW}WARN: $*${NC}" >&2
}
#######################################
# Print debug message if DEBUG is true
# Arguments:
# Message to print
#######################################
debug() {
if [[ "$DEBUG" == "true" ]]; then
echo "DEBUG: $*" >&2
fi
}
#######################################
# Print usage information
#######################################
usage() {
cat << EOF
Usage: ${SCRIPT_NAME} [OPTIONS] <argument>
Description:
Brief description of what the script does.
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
-d, --debug Enable debug output
Arguments:
argument Description of the argument
Examples:
${SCRIPT_NAME} -v myarg
${SCRIPT_NAME} --debug myarg
EOF
}
#######################################
# Cleanup function called on exit
#######################################
cleanup() {
local exit_code=$?
debug "Cleaning up..."
# Add cleanup logic here (remove temp files, etc.)
exit "$exit_code"
}
#######################################
# Main function
# Arguments:
# All command line arguments
#######################################
main() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-v|--verbose)
VERBOSE="true"
shift
;;
-d|--debug)
DEBUG="true"
shift
;;
-*)
error "Unknown option: $1"
usage
exit 1
;;
*)
break
;;
esac
done
# Validate required arguments
if [[ $# -lt 1 ]]; then
error "Missing required argument"
usage
exit 1
fi
local arg="$1"
debug "Argument: $arg"
# Main script logic here
info "Processing: $arg"
}
# Set trap for cleanup
trap cleanup EXIT
# Run main function with all arguments
main "$@"
Variables and Data Types¶
Variable Declaration¶
# Simple assignment (no spaces around =)
name="John"
count=42
# Read-only constant
readonly CONFIG_FILE="/etc/app/config"
# Export for child processes
export PATH="/usr/local/bin:$PATH"
# Default value if not set
name="${NAME:-default_value}"
# Error if not set
required_var="${REQUIRED_VAR:?Error: REQUIRED_VAR not set}"
# Command substitution
current_date="$(date +%Y-%m-%d)"
file_count="$(ls -1 | wc -l)"
Arrays¶
# Declare array
declare -a fruits=("apple" "banana" "cherry")
# Add element
fruits+=("date")
# Access element
echo "${fruits[0]}" # apple
# All elements
echo "${fruits[@]}" # apple banana cherry date
# Length
echo "${#fruits[@]}" # 4
# Iterate
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
# Associative arrays (Bash 4+)
declare -A user
user[name]="John"
user[email]="john@example.com"
echo "${user[name]}"
String Operations¶
string="Hello, World!"
# Length
echo "${#string}" # 13
# Substring
echo "${string:0:5}" # Hello
# Replace first occurrence
echo "${string/World/Bash}" # Hello, Bash!
# Replace all occurrences
echo "${string//l/L}" # HeLLo, WorLd!
# Remove prefix
filename="document.txt"
echo "${filename%.txt}" # document
# Remove suffix
path="/home/user/file.txt"
echo "${path##*/}" # file.txt (basename)
echo "${path%/*}" # /home/user (dirname)
# Uppercase/lowercase (Bash 4+)
echo "${string^^}" # HELLO, WORLD!
echo "${string,,}" # hello, world!
Control Flow¶
Conditionals¶
# If statement
if [[ "$name" == "John" ]]; then
echo "Hello, John"
elif [[ "$name" == "Jane" ]]; then
echo "Hello, Jane"
else
echo "Hello, stranger"
fi
# Test operators
[[ -z "$var" ]] # True if empty
[[ -n "$var" ]] # True if not empty
[[ "$a" == "$b" ]] # String equality
[[ "$a" != "$b" ]] # String inequality
[[ "$a" =~ ^[0-9]+$ ]] # Regex match
# Numeric comparison (use (( )) or -eq, -lt, etc.)
if (( count > 10 )); then
echo "Count is greater than 10"
fi
if [[ "$count" -gt 10 ]]; then
echo "Count is greater than 10"
fi
# File tests
[[ -f "$file" ]] # True if file exists
[[ -d "$dir" ]] # True if directory exists
[[ -r "$file" ]] # True if readable
[[ -w "$file" ]] # True if writable
[[ -x "$file" ]] # True if executable
[[ -s "$file" ]] # True if file size > 0
Loops¶
# For loop
for i in 1 2 3 4 5; do
echo "$i"
done
# C-style for loop
for ((i = 0; i < 10; i++)); do
echo "$i"
done
# Loop over array
for item in "${array[@]}"; do
echo "$item"
done
# Loop over files
for file in *.txt; do
[[ -f "$file" ]] || continue
echo "Processing: $file"
done
# While loop
count=0
while [[ $count -lt 5 ]]; do
echo "$count"
((count++))
done
# Read file line by line
while IFS= read -r line; do
echo "$line"
done < "$file"
# Until loop
until [[ -f "$file" ]]; do
echo "Waiting for $file..."
sleep 1
done
Case Statement¶
case "$command" in
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
start_service
;;
status|info) # Multiple patterns
show_status
;;
*)
echo "Unknown command: $command"
exit 1
;;
esac
Functions¶
Function Definition¶
#######################################
# Calculate sum of numbers
# Arguments:
# Numbers to sum
# Returns:
# Sum via stdout
#######################################
calculate_sum() {
local sum=0
local num
for num in "$@"; do
((sum += num))
done
echo "$sum"
}
# Call function
result="$(calculate_sum 1 2 3 4 5)"
echo "Sum: $result"
Return Values¶
# Return exit code (0 = success, 1-255 = error)
is_valid_email() {
local email="$1"
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
return 0
else
return 1
fi
}
if is_valid_email "test@example.com"; then
echo "Valid email"
fi
# Return value via stdout
get_config_value() {
local key="$1"
local config_file="${2:-/etc/app/config}"
grep "^${key}=" "$config_file" | cut -d'=' -f2
}
value="$(get_config_value "database_host")"
Input/Output¶
Reading Input¶
# Read single line
read -r -p "Enter your name: " name
# Read with timeout
if read -r -t 10 -p "Enter value (10s timeout): " value; then
echo "You entered: $value"
else
echo "Timeout!"
fi
# Read password (no echo)
read -r -s -p "Enter password: " password
echo # New line after password
# Read into array
read -r -a items <<< "one two three"
Output¶
# Stdout
echo "Normal output"
printf "Formatted: %s - %d\n" "$name" "$count"
# Stderr
echo "Error message" >&2
# Redirect stdout to file
echo "content" > file.txt # Overwrite
echo "content" >> file.txt # Append
# Redirect both stdout and stderr
command > output.log 2>&1
command &> output.log # Bash shorthand
# Discard output
command > /dev/null 2>&1
Here Documents¶
# Multi-line string
cat << EOF
This is a multi-line
string with variable expansion: $variable
EOF
# No variable expansion
cat << 'EOF'
This preserves $literal text
EOF
# Indent-friendly (<<-)
cat <<- EOF
This ignores leading tabs
for cleaner code
EOF
Error Handling¶
Exit Codes¶
# Check command success
if command; then
echo "Success"
else
echo "Failed with exit code: $?"
fi
# Chain commands
command1 && command2 # Run command2 only if command1 succeeds
command1 || command2 # Run command2 only if command1 fails
# Exit on error
set -e
command_that_might_fail || true # Continue even if fails
# Custom error handling
set -e
trap 'echo "Error on line $LINENO"; exit 1' ERR
Trap¶
# Cleanup on exit
cleanup() {
rm -f "$temp_file"
echo "Cleaned up"
}
trap cleanup EXIT
# Handle signals
trap 'echo "Interrupted"; exit 130' INT
trap 'echo "Terminated"; exit 143' TERM
# Multiple signals
trap 'cleanup' EXIT INT TERM
# Ignore signal
trap '' SIGINT
File Operations¶
Common Operations¶
# Check existence
[[ -f "$file" ]] && echo "File exists"
[[ -d "$dir" ]] && echo "Directory exists"
# Create directory (with parents)
mkdir -p "$dir/subdir"
# Copy
cp "$source" "$dest"
cp -r "$source_dir" "$dest_dir"
# Move/rename
mv "$old_name" "$new_name"
# Delete
rm "$file"
rm -rf "$dir" # Careful!
# Temporary file
temp_file="$(mktemp)"
trap 'rm -f "$temp_file"' EXIT
Text Processing¶
# Search
grep "pattern" file.txt
grep -r "pattern" directory/
grep -E "regex" file.txt
# Replace
sed 's/old/new/g' file.txt
sed -i 's/old/new/g' file.txt # In-place
# Extract columns
cut -d',' -f1,3 file.csv
awk -F',' '{print $1, $3}' file.csv
# Sort and unique
sort file.txt
sort -u file.txt # Unique
sort -n file.txt # Numeric
# Count
wc -l file.txt # Lines
wc -w file.txt # Words
Best Practices¶
Do This¶
# Quote variables
echo "$variable"
# Use [[ ]] for tests
if [[ -f "$file" ]]; then
# Use arrays for lists
files=("file1.txt" "file 2.txt" "file3.txt")
for f in "${files[@]}"; do
# Use local in functions
my_func() {
local var="value"
}
# Check command exists
if command -v docker &> /dev/null; then
docker run ...
fi
# Use parameter expansion defaults
name="${1:-default}"
Don't Do This¶
# Unquoted variables
echo $variable # Word splitting issues
# [ ] instead of [[ ]]
if [ -f $file ]; then # Breaks with spaces
# Space-separated "arrays"
files="file1 file2 file3"
for f in $files; do # Breaks with spaces in names
# Global variables in functions
my_func() {
var="value" # Pollutes global scope
}
# Parsing ls output
for f in $(ls); do # Don't do this
Common Patterns¶
Argument Parsing with getopts¶
while getopts ":hv:o:" opt; do
case $opt in
h)
usage
exit 0
;;
v)
verbose="$OPTARG"
;;
o)
output="$OPTARG"
;;
\?)
error "Invalid option: -$OPTARG"
exit 1
;;
:)
error "Option -$OPTARG requires an argument"
exit 1
;;
esac
done
shift $((OPTIND - 1))
Configuration File¶
# Load config file
if [[ -f "$config_file" ]]; then
# shellcheck source=/dev/null
source "$config_file"
fi
# Or parse key=value format
while IFS='=' read -r key value; do
[[ "$key" =~ ^#.*$ ]] && continue # Skip comments
[[ -z "$key" ]] && continue # Skip empty lines
declare "$key=$value"
done < "$config_file"
Logging¶
readonly LOG_FILE="/var/log/myapp.log"
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$timestamp] [$level] $message" >> "$LOG_FILE"
if [[ "$VERBOSE" == "true" ]]; then
echo "[$level] $message"
fi
}
log "INFO" "Starting application"
log "ERROR" "Something went wrong"
Testing¶
ShellCheck¶
# Install: apt install shellcheck / brew install shellcheck
# Run on script
shellcheck script.sh
# Disable specific check
# shellcheck disable=SC2034
unused_variable="value"
Unit Testing with Bats¶
#!/usr/bin/env bats
setup() {
# Run before each test
source ./my_script.sh
}
@test "calculate_sum adds numbers correctly" {
result="$(calculate_sum 1 2 3)"
[ "$result" -eq 6 ]
}
@test "is_valid_email validates correct email" {
run is_valid_email "test@example.com"
[ "$status" -eq 0 ]
}
@test "is_valid_email rejects invalid email" {
run is_valid_email "invalid"
[ "$status" -eq 1 ]
}