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
thenis only required whenthenis on the same line as the condition. If you putthenon 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:
- 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" ]] ``
- Logical combinations with
&&and||directly in the expression:
```bash if [[ $age -ge 18 && $id == "yes" ]]; then
echo "Access granted."fi ```
- Pattern matching with wildcards (
==):
```bash ARCHIVE="backup_2024-10.tar.gz" if [[ "$ARCHIVE" == *.tar.gz ]]; then
echo "This is a compressed tar archive."fi ```
- 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
casestatement must be terminated with two semicolons (;;)! The closing keyword isesac(caseread 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 code0).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:
- Task 1 (IP Address Validator):
Write a script
check_ip.shthat accepts an IP address as$1and checks with[[ "$1" =~ ... ]]whether it matches a valid IPv4 pattern.
- Task 2 (Port Wait Loop with Until):
Create a script that waits with
until nc -z 127.0.0.1 80; do sleep 1; doneuntil a local webserver on port 80 responds, and outputs a success message after a successful connection.
- Task 3 (Log File Filter):
Write a script that reads
/var/log/syslog(or/var/log/messages) line by line withwhile IFS= read -r lineand only outputs lines containing the worderrororfailed(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-for-dbefore 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