Bash Scripting: Variables, Loops, and Conditionals
Bash scripting transforms repetitive terminal commands into automated, reusable tools. Whether you're deploying applications, processing log files, or managing system configurations, mastering...
Key Insights
- Bash variables don’t require type declarations but demand proper quoting to avoid word splitting and glob expansion—always use
"$variable"in production scripts - Conditionals in bash use
[ ]or[[ ]]test operators with different capabilities—prefer[[ ]]for string matching and regex support, but understand both for reading legacy scripts - Combining loops with command substitution and conditionals enables powerful automation, but always validate inputs and use
set -euo pipefailto catch errors before they cascade
Introduction to Bash Scripting Fundamentals
Bash scripting transforms repetitive terminal commands into automated, reusable tools. Whether you’re deploying applications, processing log files, or managing system configurations, mastering variables, loops, and conditionals gives you the foundation to build robust shell automation.
Every bash script starts with a shebang line that tells the system which interpreter to use. The standard shebang #!/bin/bash explicitly invokes bash, while #!/usr/bin/env bash provides better portability across systems where bash might be installed in different locations.
#!/bin/bash
# Basic script structure
echo "Hello, World!"
echo "Script name: $0"
echo "First argument: $1"
Make your script executable with chmod +x script.sh and run it with ./script.sh. This simple foundation supports everything from one-liners to complex automation pipelines.
Working with Variables
Bash variables store data without type declarations. Assign values without spaces around the equals sign, and reference them with the dollar sign prefix.
#!/bin/bash
# Variable assignment
name="Application Architect"
count=42
timestamp=$(date +%Y-%m-%d)
# String interpolation
echo "Welcome to $name"
echo "Count: $count, Date: $timestamp"
# Concatenation
full_message="${name} - ${count} articles published on ${timestamp}"
echo "$full_message"
Command substitution captures output from commands into variables. Use $(command) syntax over backticks—it’s more readable and nests properly.
#!/bin/bash
# Command substitution
current_user=$(whoami)
file_count=$(ls -1 | wc -l)
disk_usage=$(df -h / | awk 'NR==2 {print $5}')
echo "User: $current_user"
echo "Files in current directory: $file_count"
echo "Root partition usage: $disk_usage"
Special variables provide script metadata and argument access:
#!/bin/bash
# Special variables
echo "Script name: $0"
echo "Number of arguments: $#"
echo "All arguments: $@"
echo "Process ID: $$"
# Run a command and check exit status
ls /nonexistent 2>/dev/null
if [ $? -ne 0 ]; then
echo "Previous command failed"
fi
# Iterate over all arguments
for arg in "$@"; do
echo "Processing: $arg"
done
Variable scope matters. By default, variables are global within a script. Use local inside functions to prevent namespace pollution.
Conditional Statements
Conditionals control script flow based on tests. The if statement evaluates conditions using test operators enclosed in brackets.
#!/bin/bash
file="/etc/hosts"
# File existence check
if [ -f "$file" ]; then
echo "$file exists and is a regular file"
if [ -r "$file" ]; then
echo "File is readable"
fi
else
echo "$file does not exist"
fi
Common file test operators include -f (regular file), -d (directory), -e (exists), -r (readable), -w (writable), and -x (executable).
Numeric comparisons use different operators than string comparisons:
#!/bin/bash
cpu_usage=85
memory_free=2048
# Numeric comparisons with elif
if [ $cpu_usage -gt 90 ]; then
echo "CRITICAL: CPU usage above 90%"
elif [ $cpu_usage -gt 75 ]; then
echo "WARNING: CPU usage above 75%"
else
echo "OK: CPU usage normal"
fi
# Multiple conditions with AND/OR
if [ $cpu_usage -gt 80 ] && [ $memory_free -lt 1024 ]; then
echo "System under high load"
fi
Numeric operators: -eq (equal), -ne (not equal), -gt (greater than), -ge (greater or equal), -lt (less than), -le (less or equal).
For multi-way branching, case statements provide cleaner syntax than multiple elif chains:
#!/bin/bash
action=$1
case $action in
start)
echo "Starting service..."
;;
stop)
echo "Stopping service..."
;;
restart)
echo "Restarting service..."
;;
status)
echo "Checking service status..."
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
The [[ ]] double bracket syntax provides enhanced features like pattern matching and regex:
#!/bin/bash
filename="report.txt"
if [[ $filename == *.txt ]]; then
echo "Text file detected"
fi
if [[ $filename =~ ^report\.[a-z]+$ ]]; then
echo "Matches report pattern"
fi
Loop Constructs
Loops automate repetitive tasks. The for loop iterates over lists or ranges.
#!/bin/bash
# Iterate over files
for file in *.log; do
if [ -f "$file" ]; then
echo "Processing: $file"
# Compress old logs
gzip "$file"
fi
done
# Iterate over a list
for server in web1 web2 web3 db1; do
echo "Checking $server..."
ping -c 1 "$server" >/dev/null 2>&1
if [ $? -eq 0 ]; then
echo "$server is up"
else
echo "$server is down"
fi
done
C-style for loops provide counter-based iteration:
#!/bin/bash
# C-style for loop
for ((i=1; i<=5; i++)); do
echo "Iteration $i"
sleep 1
done
# Countdown timer
for ((count=10; count>=1; count--)); do
echo "T-minus $count seconds"
sleep 1
done
echo "Liftoff!"
While loops execute as long as a condition remains true:
#!/bin/bash
# Read file line by line
while IFS= read -r line; do
echo "Line: $line"
done < input.txt
# Monitor until condition met
attempts=0
max_attempts=5
while [ $attempts -lt $max_attempts ]; do
if ping -c 1 google.com >/dev/null 2>&1; then
echo "Network is up"
break
fi
attempts=$((attempts + 1))
echo "Attempt $attempts failed, retrying..."
sleep 2
done
The until loop runs until a condition becomes true—opposite of while:
#!/bin/bash
counter=0
until [ $counter -gt 5 ]; do
echo "Counter: $counter"
counter=$((counter + 1))
done
Practical Application: Building a System Monitoring Script
Combine these concepts into a functional system monitoring script:
#!/bin/bash
# System monitoring script with alerts
LOG_FILE="/var/log/system_monitor.log"
DISK_THRESHOLD=80
MEMORY_THRESHOLD=90
# Function to log messages
log_message() {
local message=$1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $message" | tee -a "$LOG_FILE"
}
# Check disk usage
check_disk_usage() {
log_message "=== Checking Disk Usage ==="
while IFS= read -r line; do
usage=$(echo "$line" | awk '{print $5}' | sed 's/%//')
mount=$(echo "$line" | awk '{print $6}')
if [ "$usage" -ge "$DISK_THRESHOLD" ]; then
log_message "ALERT: Disk usage on $mount is ${usage}%"
else
log_message "OK: Disk usage on $mount is ${usage}%"
fi
done < <(df -h | grep '^/dev/')
}
# Check memory usage
check_memory() {
log_message "=== Checking Memory Usage ==="
mem_total=$(free | awk '/Mem:/ {print $2}')
mem_used=$(free | awk '/Mem:/ {print $3}')
mem_percent=$((mem_used * 100 / mem_total))
if [ $mem_percent -ge $MEMORY_THRESHOLD ]; then
log_message "ALERT: Memory usage is ${mem_percent}%"
else
log_message "OK: Memory usage is ${mem_percent}%"
fi
}
# Main execution
case ${1:-all} in
disk)
check_disk_usage
;;
memory)
check_memory
;;
all)
check_disk_usage
check_memory
;;
*)
echo "Usage: $0 {disk|memory|all}"
exit 1
;;
esac
log_message "Monitoring complete"
This script demonstrates real-world patterns: functions for organization, logging for audit trails, command-line arguments for flexibility, and threshold-based alerting.
Best Practices and Common Pitfalls
Always quote variables to prevent word splitting and glob expansion:
#!/bin/bash
# WRONG - will break with spaces
file=my document.txt
cat $file # Tries to cat "my" and "document.txt"
# CORRECT
file="my document.txt"
cat "$file" # Treats as single argument
# Iterate safely
for file in *.txt; do
[ -f "$file" ] || continue # Skip if glob doesn't match
echo "Processing: $file"
done
Enable strict error handling at the script start:
#!/bin/bash
set -euo pipefail
# -e: Exit on error
# -u: Exit on undefined variable
# -o pipefail: Exit on pipe failures
Validate inputs before using them:
#!/bin/bash
if [ $# -ne 2 ]; then
echo "Usage: $0 <source> <destination>"
exit 1
fi
source=$1
destination=$2
if [ ! -f "$source" ]; then
echo "Error: Source file '$source' does not exist"
exit 1
fi
if [ ! -d "$destination" ]; then
echo "Error: Destination directory '$destination' does not exist"
exit 1
fi
cp "$source" "$destination"
Debug scripts using set -x to print each command before execution:
#!/bin/bash
set -x # Enable debug mode
# Or enable for specific sections
set -x
complex_operation
set +x # Disable debug mode
Bash scripting mastery comes from understanding these fundamentals and applying them consistently. Start with simple automation tasks, always quote your variables, handle errors explicitly, and your scripts will be reliable tools rather than sources of mysterious failures.