Control flow
Arguments and positional parameters
Shell arguments are referenced by number, prefixed with $. The following table describes the special variables:
| Variable | Value |
|---|---|
$0 | Name of the script |
$1, $2, … | First, second, and subsequent arguments |
$# | Number of arguments passed |
$* | All arguments as a single string |
$@ | All arguments as separate words |
"$*" | All arguments joined into one string, separated by spaces |
"$@" | All arguments as individually quoted strings |
A practical example that processes all arguments passed to a script:
#!/bin/bash
echo "Script: $0"
echo "Arguments: $#"
echo "First arg: $1"
for arg in "$@"; do
echo "Processing: $arg"
done
Default values
Provide fallback values for arguments that may not be set:
PATTERN=${1:-"PDF document"} # use first arg, or default to "PDF document"
STARTDIR=${2:-.} # use second arg, or default to current directory
Control flow
if / elif / else
File and directory tests
End every if block with fi. The condition can be a command (any exit status), a test in [ ], or a compound test in [[ ]]:
if [ -f "$CONFIG" ]; then
echo "Config found: $CONFIG"
elif [ -d "$CONFIG" ]; then
echo "Error: $CONFIG is a directory, not a file" >&2
exit 1
else
echo "Config not found, using defaults"
fi
Pattern matching
Use [[ ]] for pattern matching and string comparisons, which handles special characters more safely:
if [[ $HOSTNAME == web-* ]]; then
echo "This is a web server"
fi
Commands as conditions
Exit status
Any command can serve as an if condition. The shell runs the command and tests its exit code: 0 is true, any non-zero value is false.
if touch testfile; then
echo "Created testfile"
else
echo "Could not create testfile" >&2
fi
Use it whenever a command’s success or failure is all you need:
if mkdir -p /var/app/logs; then
echo "Log directory ready"
fi
if grep -q "ERROR" /var/log/app.log; then
echo "Errors found in log"
fi
Negate a condition with !:
if ! pgrep -x nginx > /dev/null; then
echo "nginx is not running" >&2
exit 1
fi
Suppress output
When you only care whether a command succeeds, redirect its output to /dev/null to keep the terminal clean:
if ping -c 1 -W 1 "${host}" &> /dev/null; then
echo "${host} is up"
fi
&> redirects both stdout and stderr to /dev/null, discarding all output. Use it when the command’s exit code is the signal and its output would just be noise. Without it, ping prints round-trip statistics to the terminal on every check.
Use &> /dev/null when:
- Running commands in loops where per-iteration output is unwanted
- Checking reachability, process existence, or file accessibility
- The result is communicated by what your script does next, not by the command’s own output
Short-circuit operators
For simple one-liners, && and || are shorter alternatives. && runs the second command only if the first succeeds; || runs it only if the first fails:
mkdir -p /var/app/logs && echo "Directory ready"
pgrep -x nginx > /dev/null || echo "nginx is not running" >&2
Condition tests
Numeric comparisons
The following table shows numeric test operators:
| Test | True when |
|---|---|
n1 -eq n2 | n1 equals n2 |
n1 -ne n2 | n1 does not equal n2 |
n1 -gt n2 | n1 is greater than n2 |
n1 -ge n2 | n1 is greater than or equal to n2 |
n1 -lt n2 | n1 is less than n2 |
n1 -le n2 | n1 is less than or equal to n2 |
For arithmetic comparisons, (( )) is more natural and does not require the -eq syntax:
if (( disk_usage > 90 )); then
echo "Critical: disk almost full"
fi
String comparisons
The following table shows string test operators:
| Test | True when |
|---|---|
str1 = str2 | str1 equals str2 |
str1 != str2 | str1 does not equal str2 |
str1 < str2 | str1 sorts before str2 |
str1 > str2 | str1 sorts after str2 |
-n str1 | str1 is non-empty |
-z str1 | str1 is empty |
File tests
The following table shows file test operators:
| Test | True when |
|---|---|
-e file | File exists |
-f file | File exists and is a regular file |
-d file | File exists and is a directory |
-r file | File exists and is readable |
-w file | File exists and is writable |
-x file | File exists and is executable |
-s file | File exists and is not empty |
-L file | File exists and is a symbolic link |
-O file | File exists and is owned by the current user |
f1 -nt f2 | f1 is newer than f2 |
f1 -ot f2 | f1 is older than f2 |
A real-world example combining file and string tests:
#!/bin/bash
LOCKFILE="/var/run/backup.lock"
if [ -f "$LOCKFILE" ]; then
echo "Backup already running (lock file exists)" >&2
exit 1
fi
touch "$LOCKFILE"
trap "rm -f $LOCKFILE" EXIT # clean up lock file when the script exits
case statements
The case statement evaluates an EXPRESSION once and compares it against each PATTERN in order. When a PATTERN matches, its commands run and the statement exits. If no PATTERN matches, nothing runs.
case EXPRESSION in
PATTERN)
commands
;;
PATTERN | PATTERN)
commands
;;
*)
default commands
;;
esac
Each block ends with ;;. The catch-all *) matches anything and must come last. esac closes the statement.
Pattern types
| Pattern | Matches |
|---|---|
word | The exact string word |
word1 | word2 | Either word1 or word2 |
[Yy] | Any single character listed in brackets |
*.log | Any string ending in .log (glob) |
?? | Any two-character string |
* | Anything; use as the catch-all default |
Match a specific environment and fall through to a default on unknown input:
case "$ENVIRONMENT" in
production)
DB_HOST="db.prod.internal"
LOG_LEVEL="error"
;;
staging)
DB_HOST="db.staging.internal"
LOG_LEVEL="warn"
;;
development|dev)
DB_HOST="localhost"
LOG_LEVEL="debug"
;;
*)
echo "Unknown environment: $ENVIRONMENT" >&2
exit 1
;;
esac
Command dispatch
case is the standard pattern for routing subcommands in a CLI tool. The EXPRESSION is the subcommand argument; each PATTERN is a command name:
case "$1" in
start)
systemctl start myapp
;;
stop)
systemctl stop myapp
;;
restart)
systemctl restart myapp
;;
status)
systemctl status myapp
;;
help|-h|--help)
usage
;;
*)
echo "Unknown command: $1" >&2
usage
exit 2
;;
esac
Loops
Every loop uses a keyword for the loop type, the condition, then a ;do (...) done clause.
for loops
List
Iterate over a list of values:
for server in web-01 web-02 web-03; do
echo "Deploying to $server"
ssh "$server" "./deploy.sh"
done
Directory
Iterate over all files in a directory:
for file in $(ls /var/log/*.log | sort); do
if [ -f "$file" ]; then
echo "$file: $(wc -l < $file) lines"
fi
done
C-style numeric
Use C-style syntax for numeric loops:
for (( i=0; i < 10; i++ )); do
echo "Attempt $i"
done
while loops
Condition loop
Run while a condition is true:
RETRIES=0
MAX_RETRIES=5
while (( RETRIES < MAX_RETRIES )); do
if ./health_check.sh; then
echo "Service is healthy"
break
fi
(( RETRIES++ ))
echo "Attempt $RETRIES failed, retrying in 10 seconds"
sleep 10
done
Read file line by line
while read -r LINE; do
echo "Processing: $LINE"
done < /etc/hosts
The same pattern works in a script that takes arguments. This script accepts a domain and a file of subdomains, then prints each fully qualified domain name:
#!/usr/bin/env bash
DOMAIN="${1}"
FILE="${2}"
while read -r subdomain; do
echo "${subdomain}.${DOMAIN}"
done < "${FILE}"
Run it with:
bash expand-subdomains.sh example.com subdomains.txt
Pipe input
A while loop that renames files containing spaces by replacing spaces with underscores:
find /data -type f | while read -r file; do
if [[ "$file" = *[[:space:]]* ]]; then
mv "$file" "$(echo "$file" | tr ' ' '_')"
fi
done
until loops
Run until a condition becomes true (the opposite of while):
COUNT=0
until [ "$COUNT" -gt 5 ]; do
echo "Count is: $COUNT"
(( COUNT++ ))
done
Loop control keywords
| Keyword | Effect |
|---|---|
break | Exits the loop immediately |
continue | Skips to the next iteration |
exit | Exits the entire script with an optional status code |
return | Returns from a function with an optional status code |
break
Use break when you’ve found what you were looking for and continuing would waste work. It exits the loop immediately and resumes execution after done.
for loop
Search a list of servers for the first one that responds, then stop:
ACTIVE_SERVER=""
for server in web-01 web-02 web-03; do
if ping -c1 -q "$server" &>/dev/null; then
ACTIVE_SERVER="$server"
break # no need to check the rest
fi
done
if [[ -n "$ACTIVE_SERVER" ]]; then
echo "Deploying to $ACTIVE_SERVER"
else
echo "No servers available" >&2
exit 1
fi
while loop
break also works in while loops to exit once a condition is met, useful when polling for a process to start or a file to appear:
echo "Waiting for nginx..."
while true; do
if pgrep -x nginx &>/dev/null; then
echo "nginx is up"
break
fi
sleep 2
done
continue
Use continue to skip a specific iteration and move on to the next one. It keeps the loop running but avoids processing items that don’t meet a condition.
Skip empty files
Process log files but skip any that are empty:
for logfile in /var/log/*.log; do
if [[ ! -s "$logfile" ]]; then
continue # skip empty files
fi
echo "Processing $logfile ($(wc -l < "$logfile") lines)"
grep "ERROR" "$logfile" >> /tmp/errors.log
done
Skip unreachable hosts
Skip hosts that don’t respond instead of aborting the whole loop:
for host in "${HOSTS[@]}"; do
if ! ping -c1 -q "$host" &>/dev/null; then
echo "Skipping unreachable host: $host" >&2
continue
fi
ssh "$host" "./deploy.sh"
done