---
id: bash-basics-3-control-structures-in-bash
slug: bash-basics-3-control-structures-in-bash
title: "Bash Basics #3: Control Structures in Bash"
excerpt: "Master branching and loops in Bash: if-then-else, modern [[ ... ]] test operator, regex matching, case statements, for-, while-, until-loops, and select menus."
date: "2024-10-24T09:00:00+01:00"
updated: "2024-11-10T09:00:00+01:00"
author:
  name: "Sebastian Palencsar"
  handle: "spalencsar"
category: "bash-basics"
tags: ["bash", "linux", "shell", "scripting", "control-structures", "loops", "basics"]
reading_time: 20
toc: true
---

**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](/en/bash-basics/bash-basics-1-create-your-first-bash-shell-script){.badge-link-text} and deepening your understanding of data types and parameter expansions in [Bash Basics #2: Using Variables in Bash](/en/bash-basics/bash-basics-2-using-variables-in-bash){.badge-link-text}, 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.

```markdown
┌─────────────────────────────────────────────────────────────┐
│                 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

```bash
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
```

<blockquote class="infobox infobox--info">
💡 **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.
</blockquote>

```bash
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:

```markdown
┌─────────────────────────────────────────────────────────────┐
│                 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" ]]
   ```

2. **Logical combinations with `&&` and `||` directly in the expression:**
   ```bash
   if [[ $age -ge 18 && $id == "yes" ]]; then
       echo "Access granted."
   fi
   ```

3. **Pattern matching with wildcards (`==`):**
   ```bash
   ARCHIVE="backup_2024-10.tar.gz"
   if [[ "$ARCHIVE" == *.tar.gz ]]; then
       echo "This is a compressed tar archive."
   fi
   ```

4. **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 (`==`, `!=`, `<`, `<=`, `>`, `>=`):

```bash
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:

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

<blockquote class="infobox infobox--warn">
⚠️ **Syntax note:** Every branch in a `case` statement must be terminated with two semicolons (`;;`)! The closing keyword is `esac` (`case` read backwards).
</blockquote>

### Practical Example: Universal Service Manager

```bash
#!/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

```bash
# 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**:

```bash
# 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:

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

### 4. Iterating Over Files (Filename Globbing)

```bash
# 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:

```bash
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:

```bash
#!/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"
```

<blockquote class="infobox infobox--warn">
⚠️ **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.
</blockquote>

## 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.

```bash
# 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:

```bash
#!/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

<blockquote class="infobox infobox--practice">
❗ **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.
2. **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.
3. **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).
</blockquote>

### Sample Solution for Task 1:

```bash
#!/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](https://www.gnu.org/software/bash/manual/html_node/Conditional-Constructs.html){.badge-link-text} | Official GNU manual on branching and tests |
| [GNU Bash Looping Constructs](https://www.gnu.org/software/bash/manual/html_node/Looping-Constructs.html){.badge-link-text} | Official documentation on for, while, until, and select loops |
| [Bash Basics #2: Variables in Bash](/en/bash-basics/bash-basics-2-using-variables-in-bash){.badge-link-text} | The previous part: Variables, scopes, and parameter expansion |
| [Linux Command Line Processor Guide](/en/linux-beginners/command-line-processor-in-linux){.badge-link-text} | Fundamentals of I/O streams, pipes, and process management |
| [chmod and File Permissions Reference](/en/linux-beginners/basics-to-best-practices-all-about-chmod-in-linux){.badge-link-text} | 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.

<blockquote class="infobox infobox--info">
💡 **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.
</blockquote>

In the next lesson, we bring modularity and reusability into our scripts:
👉 **Next up:** [Bash Basics #4: Functions in Bash](/en/bash-basics/bash-basics-functions-in-bash){.badge-link-text}

👉 **Course overview:** [All lessons of the Bash basics course](/en/category/bash-basics){.badge-link-text}
