Freebie

Control flow

Arguments and positional parameters

Shell arguments are referenced by number, prefixed with $. The following table describes the special variables:

VariableValue
$0Name 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:

TestTrue when
n1 -eq n2n1 equals n2
n1 -ne n2n1 does not equal n2
n1 -gt n2n1 is greater than n2
n1 -ge n2n1 is greater than or equal to n2
n1 -lt n2n1 is less than n2
n1 -le n2n1 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:

TestTrue when
str1 = str2str1 equals str2
str1 != str2str1 does not equal str2
str1 < str2str1 sorts before str2
str1 > str2str1 sorts after str2
-n str1str1 is non-empty
-z str1str1 is empty

File tests

The following table shows file test operators:

TestTrue when
-e fileFile exists
-f fileFile exists and is a regular file
-d fileFile exists and is a directory
-r fileFile exists and is readable
-w fileFile exists and is writable
-x fileFile exists and is executable
-s fileFile exists and is not empty
-L fileFile exists and is a symbolic link
-O fileFile exists and is owned by the current user
f1 -nt f2f1 is newer than f2
f1 -ot f2f1 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

PatternMatches
wordThe exact string word
word1 | word2Either word1 or word2
[Yy]Any single character listed in brackets
*.logAny 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

KeywordEffect
breakExits the loop immediately
continueSkips to the next iteration
exitExits the entire script with an optional status code
returnReturns 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