Bash Basics #3: Control Structures in Bash

Master branching and loops in Bash: if-then-else, modern [[ ... ]] test operator, regex matching, case statements, for-, while-, until-loops, and select menus.

Reading time: 20 min

Welcome to the third part of our technical series on Linux and Bash programming!

After learning the fundamentals of script creation in Bash Basics #1: Create Your First Bash Shell Script and deepening your understanding of data types and parameter expansions in Bash Basics #2: Using Variables in Bash, today we focus on the logical nervous system of programming: control structures.

Pure command chains run rigidly from top to bottom. In real system administration, however, scripts must make decisions: Does a configuration file exist? Is a webserver port reachable? Has a backup command completed without errors? Do hundreds of log files need to be analyzed one by one?

In this lesson, you will learn how to precisely control the flow of your scripts: We cover branching with if-elif-else, the modern test operator [[ ... ]] with regular expressions, compact case pattern matching, for and while loops, safely reading files line by line, and interactive CLI menus with select.

Conditional Branching: if, elif, else, and fi

The if statement checks whether a command or test expression completes successfully (exit code 0). If the condition is met, the corresponding code block is executed.


┌─────────────────────────────────────────────────────────────┐
│                 CONTROL FLOW OF IF-ELIF-ELSE                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  [ Condition 1 true (Exit 0)? ]                             │
│         ├── YES ──> Execute THEN block 1 ──┐                │
│         │                                  │                │
│         └── NO                              │                │
│              │                              │                │
│              ▼                              │                │
│  [ Condition 2 true (Exit 0)? ]            │                │
│         ├── YES ──> Execute ELIF block 2 ──┤                │
│         │                                  │                │
│         └── NO                              │                │
│              │                              │                │
│              ▼                              │                │
│  [ Execute ELSE fallback block ] ───────────┤                │
│                                            ▼                │
│                                    [ Continue after FI ]    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Basic Syntax


if [[ condition1 ]]; then
    # Executed when condition 1 is true
elif [[ condition2 ]]; then
    # Executed when condition 2 is true
else
    # Fallback: Executed when no condition is met
fi

💡 Syntax tip: The semicolon before then is only required when then is on the same line as the condition. If you put then on a new line, the semicolon is omitted.


if [[ $age -ge 18 ]]
then
    echo "Of legal age"
fi

The Modern Test Operator [[ ... ]] vs. POSIX [ ... ]

In older scripts, you frequently encounter simple single brackets [ ... ]. These are a synonym for the traditional Unix test command. In modern Bash scripts, the double brackets [[ ... ]] are the undisputed gold standard:


┌─────────────────────────────────────────────────────────────┐
│                 COMPARISON: [[ ... ]] VS. [ ... ]           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   Property                 POSIX [ ... ]  Bash [[ ... ]]    │
│   ───────────────────────────────────────────────────────   │
│   Word Splitting           YES (crash!)   NO (safe)         │
│   Regex Matching (=~)      NO             YES               │
│   Pattern Matching         NO             YES (*.txt)       │
│   Logical Operators        -a / -o        && / ||           │
│   String Comparisons       \< / \>        < / >             │
│   Type of construct        External/Builtin Shell keyword   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Why [[ ... ]] is more robust and safer:

  1. No crash with incomplete variables:

``bash FILE="" # POSIX [ ]: Causes syntax error "[: =: unary operator expected" [ $FILE = "test" ] # Bash [[ ]]: Processes empty variables error-free without crashing: [[ $FILE == "test" ]] ``

  1. Logical combinations with && and || directly in the expression:

```bash if [[ $age -ge 18 && $id == "yes" ]]; then

       echo "Access granted."

fi ```

  1. Pattern matching with wildcards (==):

```bash ARCHIVE="backup_2024-10.tar.gz" if [[ "$ARCHIVE" == *.tar.gz ]]; then

       echo "This is a compressed tar archive."

fi ```

  1. Regular expressions (regex matching with =~):

```bash IP="192.168.1.100" # Check if the string matches an IPv4 structure: if [[ "$IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then

       echo "Valid IP format."

fi ```

Comparison Operators: Numbers, Strings, and Filesystem

Bash strictly distinguishes between numeric comparisons, string checks, and filesystem tests.

1. Numeric Comparisons (Integers)

Operator Meaning Example
-eq Equal [[ $a -eq $b ]]
-ne Not equal [[ $a -ne $b ]]
-lt Less than [[ $a -lt $b ]]
-le Less or equal [[ $a -le $b ]]
-gt Greater than [[ $a -gt $b ]]
-ge Greater or equal [[ $a -ge $b ]]

Alternatively, for numbers you can also use double parentheses (( ... )) with the familiar mathematical operators (==, !=, <, <=, >, >=):


if (( a >= 18 )); then
    echo "Of legal age via arithmetic evaluation."
fi

2. String Comparisons

Operator Meaning Example
== or = Strings are identical [[ "$a" == "$b" ]]
!= Strings are different [[ "$a" != "$b" ]]
-z String is empty (zero length) [[ -z "$a" ]]
-n String is not empty [[ -n "$a" ]]
< / > Lexicographic sorting [[ "$a" < "$b" ]]

3. Filesystem Tests (File Test Operators)

File operators are the most important tool for administration scripts:

Operator Checks if... Use Case
-f file ...a regular file exists [[ -f /etc/nginx/nginx.conf ]]
-d dir ...a directory exists [[ -d /var/log/nginx ]]
-e path ...the path exists (file, folder, socket) [[ -e /tmp/app.sock ]]
-s file ...the file exists and is not empty ($> 0$ bytes) [[ -s /var/log/errors.log ]]
-r file ...the file is readable by the user [[ -r secret.key ]]
-w file ...the file is writable [[ -w /var/log/app.log ]]
-x file ...the file is executable (execute bit) [[ -x /usr/local/bin/deploy ]]
-L link ...the path is a symbolic link (symlink) [[ -L /bin/sh ]]
f1 -nt f2 ...file 1 is newer than file 2 (newer than) [[ source.c -nt binary ]]
f1 -ot f2 ...file 1 is older than file 2 (older than) [[ backup.tar -ot current.tar ]]

Multiple Branches with case ... esac

When a variable needs to be checked against many different patterns, case is significantly clearer and faster than nested if-elif-else structures:


case "$VARIABLE" in
    pattern1)
        # Commands for pattern1
        ;;
    pattern2|pattern3)
        # Multiple patterns linked with pipe (|)
        ;;
    *)
        # Default branch (equivalent to 'else')
        ;;
esac

⚠️ Syntax note: Every branch in a case statement must be terminated with two semicolons (;;)! The closing keyword is esac (case read backwards).

Practical Example: Universal Service Manager


#!/usr/bin/env bash
set -euo pipefail

ACTION="${1:-status}"
SERVICE="${2:-nginx}"

case "$ACTION" in
    start)
        echo "Starting service $SERVICE..."
        sudo systemctl start "$SERVICE"
        ;;
    stop)
        echo "Stopping service $SERVICE..."
        sudo systemctl stop "$SERVICE"
        ;;
    restart|reload)
        echo "Reloading service $SERVICE..."
        sudo systemctl restart "$SERVICE"
        ;;
    status)
        sudo systemctl status "$SERVICE" --no-pager
        ;;
    *)
        echo "Error: Unknown action '$ACTION'!" >&2
        echo "Usage: $0 {start|stop|restart|status} [service-name]" >&2
        exit 1
        ;;
esac

For Loops: Iterating Over Lists, Sequences, and Arrays

The for loop repeats a code block for each element of an enumeration:

1. Iterating Over Lists and Arrays


# Iterate over an indexed array
SERVERS=("web01" "web02" "db01" "cache01")

for server in "${SERVERS[@]}"; do
    echo "Checking reachability of $server..."
done

2. Iterating Over Number Ranges with Brace Expansion

Avoid the outdated subprocess call for i in $(seq 1 10). Instead, use Bash's native brace expansion:


# Count from 1 to 5
for i in {1..5}; do
    echo "Iteration: $i"
done

# Count in steps of 5 from 0 to 20 ({start..end..step}):
for port in {8080..8090..2}; do
    echo "Testing port $port..."
done

3. C-Style For Loops

For counters and calculations, Bash supports the classic C syntax:


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

4. Iterating Over Files (Filename Globbing)


# Process all log files in /var/log
for logfile in /var/log/*.log; do
    # Important: Check if the file actually exists (in case no .log files are present)
    [[ -f "$logfile" ]] || continue
    
    echo "Compressing: $logfile..."
    gzip -k "$logfile"
done

While and Until Loops

  • while: Executes the code block as long as the condition is true (exit code 0).
  • until: Executes the code block until the condition becomes true (while it is false).

Counter-Based While Loop:


counter=1

while [[ $counter -le 5 ]]; do
    echo "Step $counter of 5"
    (( counter++ ))
done

Best Practice Gold Standard: Safely Reading Files Line by Line

To process log files, server lists, or CSVs line by line, the combination of while and read -r is the absolute best practice:


#!/usr/bin/env bash
set -euo pipefail

HOSTS_FILE="/etc/hosts"

# IFS= prevents trimming of leading whitespace
# -r prevents misinterpretation of backslashes
while IFS= read -r line || [[ -n "$line" ]]; do
    # Skip empty lines and comment lines:
    [[ -z "$line" || "$line" =~ ^# ]] && continue
    
    echo "Entry: $line"
done < "$HOSTS_FILE"

⚠️ Anti-pattern warning: Never use for line in $(cat file.txt). This command splits lines at every whitespace (word splitting) and breaks on empty lines or special characters.

Loop Control: break and continue

With break and continue, you can directly intervene in the flow of loops:

  • continue: Aborts the current iteration prematurely and jumps immediately to the next iteration.
  • break: Terminates the entire loop immediately.

# Example: Search for a file and terminate the loop on the first match
for file in /tmp/reports/*; do
    [[ -f "$file" ]] || continue
    
    if [[ "$file" == *"critical.log"* ]]; then
        echo "Critical file found: $file!"
        break  # Terminate loop immediately
    fi
done

With nested loops, you can use break 2 or continue 2 to exit multiple loop levels at once.

Interactive CLI Menus with select

Bash provides the select construct as a built-in way to create interactive text-based selection menus without external libraries:


#!/usr/bin/env bash
set -euo pipefail

PS3="Please choose an option (1-4): "
options=("System Status" "Show Disk Usage" "Restart Nginx" "Quit")

select opt in "${options[@]}"; do
    case "$opt" in
        "System Status")
            uptime
            ;;
        "Show Disk Usage")
            df -h /
            ;;
        "Restart Nginx")
            sudo systemctl restart nginx
            echo "Nginx has been restarted."
            ;;
        "Quit")
            echo "Goodbye!"
            break
            ;;
        *)
            echo "Invalid input! Please enter a number between 1 and 4."
            ;;
    esac
done

Exercises & Practice Check

Practice tasks for lesson #3:

  1. Task 1 (IP Address Validator):

Write a script check_ip.sh that accepts an IP address as $1 and checks with [[ "$1" =~ ... ]] whether it matches a valid IPv4 pattern.

  1. Task 2 (Port Wait Loop with Until):

Create a script that waits with until nc -z 127.0.0.1 80; do sleep 1; done until a local webserver on port 80 responds, and outputs a success message after a successful connection.

  1. Task 3 (Log File Filter):

Write a script that reads /var/log/syslog (or /var/log/messages) line by line with while IFS= read -r line and only outputs lines containing the word error or failed (case-insensitive).

Sample Solution for Task 1:


#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

IP="${1:?Please provide an IP address as parameter!}"

# Regex for 4 number blocks from 0-255
REGEX='^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$'

if [[ "$IP" =~ $REGEX ]]; then
    echo "OK: '$IP' is a formally valid IPv4 address."
    exit 0
else
    echo "ERROR: '$IP' is not a valid IPv4 address!" >&2
    exit 1
fi

Command Reference (Cheatsheet)

Construct / Operator Function & Description
if [[ condition ]]; then ... fi Standard branching in Bash
elif [[ condition ]]; then Additional condition in the branching tree
case "$var" in pattern) ... ;; esac Multiple branches with pattern matching
[[ -f $file ]] True if regular file exists
[[ -d $dir ]] True if directory exists
[[ -s $file ]] True if file exists and is not empty ($> 0$ bytes)
[[ -z $str ]] True if string has length 0 (is empty)
[[ -n $str ]] True if string is not empty
[[ $str =~ regex ]] Tests string against a regular expression
for item in "${arr[@]}"; do ... done Iterates over all elements of an array
for (( i=0; i<n; i++ )); do C-style counting loop
while IFS= read -r line; do ... done < file Safely reading files line by line
select opt in "${opts[@]}"; do Creates an interactive CLI selection menu
break / continue Terminates loop or skips current iteration

Further Resources

Resource Description
GNU Bash Conditional Constructs Official GNU manual on branching and tests
GNU Bash Looping Constructs Official documentation on for, while, until, and select loops
Bash Basics #2: Variables in Bash The previous part: Variables, scopes, and parameter expansion
Linux Command Line Processor Guide Fundamentals of I/O streams, pipes, and process management
chmod and File Permissions Reference Permission bits for scripts and directories

Conclusion

With branching, robust tests via [[ ... ]], and loops, you now have the tools to transform scripts from simple command sequences into intelligent, fault-tolerant system programs. Choosing the right control structure – whether compact case for status queries or while read -r for file processing – ensures readable, maintainable, and safe code.

💡 Tip: In modern Bash scripts, consistently use the double test operator [[ ... ]] instead of the outdated POSIX brackets [ ... ]. When checking files, always combine it with -f or -d before starting read or write operations.

In the next lesson, we bring modularity and reusability into our scripts: 👉 Next up: Bash Basics #4: Functions in Bash

👉 Course overview: All lessons of the Bash basics course

Share & export

Export as Markdown