Freebie

Writing bash scripts

Scripts turn a manual sequence of steps into a repeatable, automated process. A script that backs up a database, sends an alert when disk space runs low, or renames a thousand files eliminates repetitive work and removes the chance of human error from routine operations. When something does go wrong, a well-written script produces clear exit codes and error messages that make the failure easy to diagnose.

This page covers everything you need to write reliable bash scripts: from the first line of the file to argument parsing, control flow, math, and signal handling.

Script structure

A bash script is a plain text file with execute permissions. The first line, called the shebang, tells the operating system which interpreter to run:

#!/bin/bash

An alternative form locates the bash executable via PATH, which improves portability across systems where bash lives in a non-standard location:

#!/usr/bin/env bash

Store scripts that you want available system-wide in /usr/local/bin. Store personal scripts in ~/bin and add that directory to your PATH.

Running a script

Before you can run a script, set the execute permission with chmod:

chmod +x my_script.sh
./my_script.sh

The ./ prefix is required because the current directory is not in PATH by default. This is a deliberate security decision: it prevents a malicious file named ls or cp from running in place of the real command.

To run a script in a specific shell without setting execute permissions, pass it as an argument:

bash my_script.sh

Debugging

Add the -x flag to the shebang to print each command as the shell executes it. The shell prefixes each command with +:

#!/bin/bash -x

For example, running a script with debugging enabled produces output like this:

+ DIR=.
+ find . -type f
+ read file
+ [[ ./config.sh = *[[:space:]]* ]]

You can also enable debugging for a specific section of a script by wrapping it:

set -x          # enable debugging
mv "$old" "$new"
set +x          # disable debugging

Two other useful options to add to scripts are set -e, which exits immediately on any error, and set -u, which treats unset variables as errors. Together, they catch many common mistakes:

#!/bin/bash
set -eu

Displaying output

echo prints a message followed by a newline. printf gives you more control over formatting:

echo "Backup started"
echo                    # print a blank line

printf "%-15s %8d\n" "$hostname" "$count"

Quoting

Single quotes, double quotes, and backticks have different effects on how the shell evaluates content inside them:

  • Single quotes (''): Every character is literal. The shell does not expand variables or interpret escape sequences.
  • Double quotes (""): Allow variable expansion ($), command substitution (` ` or $()), and escape sequences (\).
  • Backticks (` `): Execute a command and substitute its output. Prefer $() instead – it is easier to read and supports nesting.

For example, compare the three behaviors:

BASH_VAR="hello"
echo '$BASH_VAR'               # $BASH_VAR (literal)
echo "$BASH_VAR"               # hello (expanded)
echo "Today is $(date +%A)"    # Today is Monday (command substituted)

ANSI-C escape characters

When you need special characters in a string, use the $'...' syntax to enable ANSI-C escape sequences:

SequenceValue
\nNewline
\tHorizontal tab
\rCarriage return
\aBell (alert)
\bBackspace
\\Backslash
\xnnHex value of a character
\nnnOctal value of a character

For example, embed a newline and a hex character in a string:

echo $'Line one\nLine two'
echo $'email: user\x40example.com'    # \x40 is the hex code for @

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

Exit status

Every command returns an exit status: 0 means success, and any non-zero value means failure. The special variable $? holds the exit status of the most recent command:

ls /etc/hosts
echo $?    # 0 (success)

ls /nonexistent
echo $?    # 2 (failure)

Scripts exit with the status of their last command by default. Control this explicitly with the exit command to signal specific error conditions:

#!/bin/bash
if [ ! -d "$1" ]; then
    echo "Error: directory '$1' does not exist" >&2
    exit 1
fi
# continue processing...
exit 0

Writing errors to stderr (>&2) keeps them separate from normal output and makes them visible even when stdout is redirected.

Variables

User-defined variables

Define a variable with = and no spaces on either side. Reference it with $:

days=10
guest="Alice"
logfile="/var/log/app.log"

echo "$guest checked in $days days ago"
echo "Watching $logfile"

Environment variables

Run export to make a variable available to child processes. Scripts launched from your terminal can then read it:

export BACKUP_DIR="/mnt/backups"
./run_backup.sh    # script reads $BACKUP_DIR

Run set to display all global variables. Run printenv to display only exported environment variables.

Local variables in functions

Variables defined inside a function are global by default. Declare them with local to restrict their scope:

function report {
    local count=0
    count=$(grep -c "ERROR" "$1")
    echo "Found $count errors in $1"
}

Text manipulation

Three built-in expansion features let you manipulate string values without calling external tools:

Globbing lets you match multiple filenames with wildcard patterns in your script.

Parameter expansion lets you extract substrings, apply default values, and transform variable content. For example, ${VAR#pattern} removes the shortest match of pattern from the left, and ${VAR%pattern} removes from the right.

String slicing lets you remove or replace substrings:

STRING="user|admin|root"
FIRST=${STRING%%|*}        # removes everything to the right of the first |: "user"
REST=${STRING#*|}          # removes the first field and |: "admin|root"
CLEAN=${STRING//|/,}       # replaces all | with ,: "user,admin,root"

Case conversion

Convert a string between upper and lowercase with tr:

upper=$(echo "$var" | tr '[a-z]' '[A-Z]')
lower=$(echo "$var" | tr '[A-Z]' '[a-z]')

The typeset command enforces case at assignment time. The value is always stored in the specified case regardless of what you assign:

typeset -u HOSTNAME_UPPER
HOSTNAME_UPPER="web-01"
echo $HOSTNAME_UPPER    # WEB-01

typeset -l env_name
env_name="PRODUCTION"
echo $env_name          # production

Arrays

Bash arrays store multiple values indexed by number:

SERVERS=("web-01" "web-02" "db-01" "cache-01")

echo ${SERVERS[0]}          # web-01 (first element)
echo ${#SERVERS[@]}         # 4 (number of elements)

for server in "${SERVERS[@]}"; do
    ssh "$server" uptime
done

Associative arrays (Bash 4.0+)

Associative arrays store values indexed by string keys:

declare -A PORTS
PORTS["http"]=80
PORTS["https"]=443
PORTS["ssh"]=22

echo ${PORTS["https"]}          # 443

for service in "${!PORTS[@]}"; do   # ${!array[@]} gets the keys
    echo "$service: ${PORTS[$service]}"
done

Reading a file into an array

Read a file line by line into an array with readarray. The -t flag strips the trailing newline from each element:

readarray -t LINES < /etc/hosts

for line in "${LINES[@]}"; do
    echo "$line"
done

User input

Read input from the user with the read command:

#!/bin/bash
echo -n "Enter the target host: "
read TARGET_HOST

echo -n "Enter username: "
read -s USERNAME    # -s suppresses echoing (useful for passwords)

echo "Connecting to $USERNAME@$TARGET_HOST"

Read multiple values in one command, where read assigns each word to the corresponding variable:

read HOST PORT <<< "db.internal 5432"
echo "$HOST on port $PORT"

Here documents

A here document supplies multi-line input to a command without requiring a separate file. Everything between the opening and closing delimiter is treated as stdin:

cat << EOF > /etc/motd
Welcome to $(hostname)
Last updated: $(date)
Unauthorized access is prohibited.
EOF

Here documents are also useful for generating configuration from a script:

ssh user@remote bash << 'EOF'
set -eu
echo "Running on $(hostname)"
df -h /
uptime
EOF

Quoting the opening delimiter ('EOF') prevents the local shell from expanding variables inside the document. The remote shell receives the literal text and expands it on its end.

Command substitution

Command substitution runs a command and replaces the expression with its output. This lets you capture the output of a command in a variable:

TODAY=$(date +%Y-%m-%d)
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}')
echo "Disk usage on $TODAY: $DISK_USAGE"

Nest command substitutions to build complex single-line expressions:

echo "Today is $(echo $(date +%A) | tr a-z A-Z)!"    # Today is MONDAY!

Functions

Functions group commands under a name so you can call them multiple times. Define a function before calling it:

function check_disk {
    local threshold=$1
    local usage
    usage=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
    if (( usage > threshold )); then
        echo "WARNING: disk usage at ${usage}%" >&2
        return 1
    fi
    echo "Disk usage OK: ${usage}%"
    return 0
}

check_disk 80

Inside a function, positional parameters ($1, $2, …) refer to the function’s own arguments, not the script’s arguments. $0 still refers to the script name. $# is the number of arguments passed to the function.

Functions return a status code. To return a value other than a status, store it in a variable:

function get_ip {
    local RESULT
    RESULT=$(hostname -I | awk '{print $1}')
    echo "$RESULT"
}

MY_IP=$(get_ip)
echo "My IP: $MY_IP"

Control flow

if / elif / else

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

Use [[ ]] for pattern matching and string comparisons, which handles special characters more safely:

if [[ $HOSTNAME == web-* ]]; then
    echo "This is a web server"
fi

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 matches a value against patterns and runs the corresponding commands. It is the bash equivalent of switch in other languages. End each case block with ;; and the default case with *:

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

Loops

End every loop with done.

for loops

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

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

Use C-style syntax for numeric loops:

for (( i=0; i < 10; i++ )); do
    echo "Attempt $i"
done

while loops

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 a file line by line with a while loop:

while read -r LINE; do
    echo "Processing: $LINE"
done < /etc/hosts

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

The following table describes 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

Math

Integer arithmetic

The let command evaluates an arithmetic expression:

let SUM=$1+$2
let RESULT=COUNT*2

Double parentheses (( )) provide arithmetic evaluation without needing $ to reference variables:

(( result = COUNT * i / maxcount ))
echo $result

Arithmetic expansion $(( )) evaluates an expression and substitutes the result:

echo $(( $1 + $2 ))
echo $(( 2 ** 10 ))    # 1024

Declare a variable as an integer with declare -i to prevent non-integer assignments:

declare -i total
total=0
total+=5

Declare local integers inside functions:

function calculate {
    local -i result
    result=$(( $1 * $2 ))
    echo $result
}

Floating-point arithmetic with bc

Bash integer arithmetic truncates decimals. For floating-point calculations, pipe to bc:

result=$(echo "scale=4; 3.14159 * 2.5 * 2.5" | bc)
echo "Area: $result"

The scale variable sets the number of decimal places. Run bc interactively:

bc -q        # quiet mode, no copyright notice
3.44 / 5
0
scale=4
3.44 / 5
.6880
quit

A real-world example: calculate the percentage of disk space used:

USED=$(df / | awk 'NR==2 {print $3}')
TOTAL=$(df / | awk 'NR==2 {print $2}')
PCT=$(echo "scale=1; $USED / $TOTAL * 100" | bc)
echo "Disk usage: ${PCT}%"

Traps and signals

When a process receives a signal, it can run a trap: a handler function that cleans up before the process exits. List all available signals with kill -l.

The most common trap cleans up temporary files when a script is interrupted:

TMPFILE=$(mktemp)

function cleanup {
    rm -f "$TMPFILE"
    echo "Cleaned up temporary files"
}

trap cleanup EXIT INT TERM

# the rest of the script can write to $TMPFILE safely

EXIT runs when the script exits for any reason. INT handles Ctrl+C. TERM handles kill commands.

A trap that catches Ctrl+C during a loop and exits gracefully:

trap 'echo "Interrupted -- exiting cleanly"; exit 1' INT

for host in "${HOSTS[@]}"; do
    echo "Scanning $host"
    nmap "$host"
done

getopts for CLI argument parsing

getopts parses command-line flags in a standard format. The option string lists accepted flags. A colon after a flag means it takes an argument:

#!/bin/bash
# Usage: ./script.sh -c /dest -i -r /source

while getopts 'c:irR' opt; do
    case "${opt}" in
    c)
        COPY=YES
        DESTDIR="$OPTARG"
        ;;
    i)
        CASEMATCH='-i'
        ;;
    [Rr])
        RECURSIVE=YES
        ;;
    *)
        echo "Usage: $0 [-c destdir] [-i] [-r] source" >&2
        exit 2
        ;;
    esac
done

# shift parsed args so $1 now refers to the first non-flag argument
shift $(( OPTIND - 1 ))
SOURCE="$1"

After getopts processes all flags, shift $(( OPTIND - 1 )) resets the argument list so positional parameters start at the first non-flag argument.

Process management

ps command options

The following table shows common ps command options:

CommandOutput
psProcesses running in the current terminal
ps -fFull listing for the current user
ps -efFull listing of all user processes
ps -AAll processes including kernel processes
ps auxWide listing with CPU and memory usage
ps auxwWide listing sorted by CPU percentage

A script that checks whether a specific process is running and restarts it if not:

#!/bin/bash
SERVICE="nginx"

if ! pgrep -x "$SERVICE" > /dev/null; then
    echo "$SERVICE is not running, starting it" | tee -a /var/log/watchdog.log
    systemctl start "$SERVICE"
fi