Comprehensive guide to shell scripting with Bash and Zsh, covering fundamentals, best practices, and common patterns.
- Getting Started
- Script Basics
- Variables
- Control Structures
- Functions
- Error Handling
- Common Patterns
- Best Practices
- Testing and Debugging
#!/bin/bash
# or
#!/bin/zsh
echo "Hello, World!"Make it executable:
chmod +x script.sh
./script.sh| Shebang | Shell | Use Case |
|---|---|---|
#!/bin/bash |
Bash | System bash (may be old) |
#!/usr/bin/env bash |
Bash | Use bash from PATH (recommended) |
#!/bin/zsh |
Zsh | System zsh |
#!/usr/bin/env zsh |
Zsh | Use zsh from PATH (recommended) |
#!/bin/sh |
POSIX sh | Maximum compatibility |
Tip
Use #!/usr/bin/env bash for better portability across different systems.
# Single line comment
: << 'COMMENT'
Multi-line comment
Everything here is ignored
COMMENT# Print to stdout
echo "Hello"
printf "Name: %s\n" "$name"
# Read from stdin
read -p "Enter your name: " name
read -sp "Enter password: " password # Silent input
# Here document
cat << EOF
Multiple lines
of text
EOF
# Here string
grep "pattern" <<< "search this string"# Success
exit 0
# Error
exit 1
# Check exit code
if [ $? -eq 0 ]; then
echo "Success"
fi
# Different error codes
exit 1 # General error
exit 2 # Misuse of command
exit 127 # Command not found
exit 130 # Ctrl+C# Basic assignment (no spaces around =)
name="Alice"
count=42
# Command substitution
current_dir=$(pwd)
files=$(ls)
# Read from file
content=$(cat file.txt)
# Arithmetic
((count++))
((result = 5 + 3))name="Alice"
# Basic expansion
echo "$name" # Alice
echo "${name}" # Alice (preferred)
# Default values
echo "${name:-Guest}" # Use "Guest" if name is unset/empty
echo "${name:=Guest}" # Set and use "Guest" if unset/empty
echo "${name:?Error}" # Error if name is unset/empty
# String manipulation
echo "${name,,}" # alice (lowercase)
echo "${name^^}" # ALICE (uppercase)
echo "${#name}" # 5 (length)
# Substrings
text="Hello World"
echo "${text:0:5}" # Hello
echo "${text:6}" # World
echo "${text: -5}" # World (last 5 chars)
# Search and replace
echo "${text/World/Universe}" # Hello Universe (first)
echo "${text//o/0}" # Hell0 W0rld (all)# Declaration
fruits=("apple" "banana" "cherry")
declare -a numbers=(1 2 3 4 5)
# Access (0-indexed)
echo "${fruits[0]}" # apple
echo "${fruits[@]}" # All elements
echo "${!fruits[@]}" # All indices
echo "${#fruits[@]}" # Length
# Iteration
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
# Add elements
fruits+=("date")
# Associative arrays (Bash 4+)
declare -A person
person[name]="Alice"
person[age]=30
echo "${person[name]}" # Alice
for key in "${!person[@]}"; do
echo "$key: ${person[$key]}"
done# Declaration (1-indexed!)
fruits=(apple banana cherry)
# Access (1-indexed!)
echo $fruits[1] # apple (first element)
echo $fruits[-1] # cherry (last element)
echo $fruits[2,3] # banana cherry (slice)
echo ${#fruits} # 3 (length)
# All elements
echo ${fruits[@]}
echo ${fruits[*]}
# Associative arrays
typeset -A person
person[name]="Alice"
person[age]=30
# Iteration
for fruit in $fruits; do
echo $fruit
done
for key value in ${(kv)person}; do
echo "$key: $value"
done# if-elif-else
if [ "$count" -gt 10 ]; then
echo "Greater than 10"
elif [ "$count" -eq 10 ]; then
echo "Equals 10"
else
echo "Less than 10"
fi
# Modern test [[ ]] (Bash/Zsh)
if [[ "$name" == "Alice" && "$age" -gt 18 ]]; then
echo "Adult Alice"
fi
# One-liner
[ -f "file.txt" ] && echo "File exists"
# Case statement
case "$1" in
start)
echo "Starting..."
;;
stop)
echo "Stopping..."
;;
restart)
echo "Restarting..."
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac[ -e file ] # Exists
[ -f file ] # Regular file
[ -d dir ] # Directory
[ -L link ] # Symbolic link
[ -r file ] # Readable
[ -w file ] # Writable
[ -x file ] # Executable
[ -s file ] # Not empty
[ file1 -nt file2 ] # file1 newer than file2
[ file1 -ot file2 ] # file1 older than file2[ -z "$str" ] # Empty string
[ -n "$str" ] # Non-empty string
[ "$a" = "$b" ] # Equal (POSIX)
[ "$a" == "$b" ] # Equal (Bash/Zsh)
[ "$a" != "$b" ] # Not equal
[[ "$a" < "$b" ]] # Lexicographic comparison
[[ "$a" =~ regex ]] # Regex match (Bash/Zsh)[ "$a" -eq "$b" ] # Equal
[ "$a" -ne "$b" ] # Not equal
[ "$a" -lt "$b" ] # Less than
[ "$a" -le "$b" ] # Less than or equal
[ "$a" -gt "$b" ] # Greater than
[ "$a" -ge "$b" ] # Greater than or equal[ cond1 ] && [ cond2 ] # AND
[ cond1 ] || [ cond2 ] # OR
! [ cond ] # NOT
# Modern [[ ]]
[[ cond1 && cond2 ]]
[[ cond1 || cond2 ]]
[[ ! cond ]]# for loop
for i in 1 2 3 4 5; do
echo "$i"
done
# C-style for
for ((i=0; i<10; i++)); do
echo "$i"
done
# Range expansion
for i in {1..10}; do
echo "$i"
done
# Iterate files
for file in *.txt; do
echo "Processing $file"
done
# while loop
count=0
while [ $count -lt 10 ]; do
echo "$count"
((count++))
done
# Read file line by line
while IFS= read -r line; do
echo "$line"
done < file.txt
# until loop
count=0
until [ $count -ge 10 ]; do
echo "$count"
((count++))
done
# Loop control
for i in {1..10}; do
[ $i -eq 5 ] && continue # Skip 5
[ $i -eq 8 ] && break # Stop at 8
echo "$i"
done# Definition
greet() {
echo "Hello, $1!"
}
# Alternative syntax
function greet {
echo "Hello, $1!"
}
# Call
greet "Alice" # Hello, Alice!process() {
local file="$1" # First argument
local mode="${2:-default}" # Second arg or "default"
echo "File: $file"
echo "Mode: $mode"
echo "All args: $@"
echo "Num args: $#"
}
process "file.txt" "verbose"# Return exit code (0-255)
is_even() {
local num=$1
if (( num % 2 == 0 )); then
return 0 # Success
else
return 1 # Failure
fi
}
if is_even 4; then
echo "Even"
fi
# Return string via stdout
get_name() {
echo "Alice"
}
name=$(get_name)
echo "$name"
# Return multiple values
get_person() {
echo "Alice"
echo "30"
echo "Engineer"
}
read name age job <<< "$(get_person | tr '\n' ' ')"global_var="global"
my_function() {
local local_var="local"
global_var="modified"
echo "Local: $local_var"
echo "Global: $global_var"
}
my_function
echo "Global outside: $global_var" # modified
# echo "$local_var" # Error: not defined#!/bin/bash
set -e # Exit on error
set -u # Error on undefined variable
set -o pipefail # Fail on pipe errors
# Combined
set -euo pipefail
# Or in shebang
#!/bin/bash -euo pipefail# Check command success
if ! command -v git &> /dev/null; then
echo "Git not found"
exit 1
fi
# Check file exists
if [ ! -f "config.txt" ]; then
echo "Config file not found"
exit 1
fi
# Or with ||
cd /some/dir || exit 1# Cleanup on exit
cleanup() {
echo "Cleaning up..."
rm -f /tmp/tempfile
}
trap cleanup EXIT
# Handle errors
error_handler() {
echo "Error on line $1"
exit 1
}
trap 'error_handler $LINENO' ERR
# Handle signals
trap 'echo "Interrupted"; exit 130' INT
trap 'echo "Terminated"; exit 143' TERM#!/bin/bash
set -euo pipefail
# Error handler
err_report() {
echo "Error on line $1" >&2
exit 1
}
trap 'err_report $LINENO' ERR
# Cleanup handler
cleanup() {
local exit_code=$?
echo "Cleaning up..."
# Cleanup code here
exit $exit_code
}
trap cleanup EXIT
# Your script here
main() {
echo "Starting..."
# Your code
}
main "$@"# Simple argument parsing
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-v|--verbose)
VERBOSE=1
shift
;;
-o|--output)
OUTPUT_FILE="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Using getopts (single-char options)
while getopts "hvo:" opt; do
case $opt in
h) show_help; exit 0 ;;
v) VERBOSE=1 ;;
o) OUTPUT_FILE="$OPTARG" ;;
?) exit 1 ;;
esac
done
shift $((OPTIND-1))# Check required arguments
if [ $# -eq 0 ]; then
echo "Usage: $0 <filename>"
exit 1
fi
# Validate file exists
file="$1"
if [ ! -f "$file" ]; then
echo "Error: File '$file' not found"
exit 1
fi
# Validate number
if ! [[ "$1" =~ ^[0-9]+$ ]]; then
echo "Error: Not a valid number"
exit 1
fi# Load configuration
CONFIG_FILE="${1:-config.sh}"
if [ -f "$CONFIG_FILE" ]; then
source "$CONFIG_FILE"
else
echo "Config file not found: $CONFIG_FILE"
exit 1
fi
# Safe config loading
load_config() {
local config_file="$1"
if [ -f "$config_file" ]; then
# Only allow variable assignments
while IFS='=' read -r key value; do
# Skip comments and empty lines
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "$key" ]] && continue
# Export variable
export "$key"="$value"
done < "$config_file"
fi
}# Simple logging
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
log "Starting process..."
log "Processing file: $file"
# Log levels
log_info() {
echo "[INFO] $(date '+%H:%M:%S') - $*"
}
log_error() {
echo "[ERROR] $(date '+%H:%M:%S') - $*" >&2
}
log_debug() {
[ "$DEBUG" = "1" ] && echo "[DEBUG] $(date '+%H:%M:%S') - $*"
}# Run in background
long_task &
pid=$!
echo "Running with PID: $pid"
# Wait for completion
wait $pid
echo "Task completed with exit code: $?"
# Timeout
timeout 30s long_task || echo "Timed out"
# Check if process is running
if ps -p $pid > /dev/null; then
echo "Process is running"
fi#!/bin/bash
set -euo pipefail
IFS=$'\n\t'# ✓ Good
echo "$variable"
rm "$file"
cd "$directory"
# ✗ Bad (word splitting issues)
echo $variable
rm $file# ✓ Good - modular and reusable
check_requirements() {
command -v git &> /dev/null || {
echo "Git not found"
return 1
}
}
main() {
check_requirements || exit 1
# Main logic
}
main "$@"
# ✗ Bad - everything in global scope
command -v git &> /dev/null || exit 1
# Script continues...# Install ShellCheck
# macOS: brew install shellcheck
# Ubuntu: apt install shellcheck
# Check script
shellcheck script.sh#!/bin/bash
#
# backup.sh - Backup important directories
#
# Usage: backup.sh [options] <source> <destination>
#
# Options:
# -v, --verbose Enable verbose output
# -h, --help Show this help message
#
# Examples:
# backup.sh ~/Documents /mnt/backup
# backup.sh -v ~/Pictures /mnt/backup
# Function documentation
## Backs up a directory to destination
## Arguments:
## $1 - Source directory
## $2 - Destination directory
## Returns:
## 0 on success, 1 on failure
backup_directory() {
local src="$1"
local dest="$2"
# Implementation
}# Check prerequisites
command -v required_tool &> /dev/null || {
echo "Error: required_tool not found"
exit 1
}
# Validate input
if [ $# -eq 0 ]; then
echo "Usage: $0 <arg>"
exit 1
fi
# Check file permissions
if [ ! -r "$file" ]; then
echo "Error: Cannot read file: $file"
exit 1
fi# Enable debugging
bash -x script.sh
# Or in script
set -x # Enable
set +x # Disable
# Conditional debugging
[ "$DEBUG" = "1" ] && set -x# Install: brew install bats-core
# test.bats
#!/usr/bin/env bats
@test "addition" {
result="$(( 2 + 2 ))"
[ "$result" -eq 4 ]
}
@test "file exists" {
[ -f "README.md" ]
}
# Run tests
bats test.batsDRY_RUN="${DRY_RUN:-0}"
run_command() {
if [ "$DRY_RUN" = "1" ]; then
echo "Would run: $*"
else
"$@"
fi
}
# Usage
run_command rm -f file.txt
# Run in dry-run mode
DRY_RUN=1 ./script.sh- Bash Guide - Bash-specific features
- Zsh Guide - Zsh-specific features
- Configuration Guide - Shell configuration patterns
- Troubleshooting - Common issues and solutions