Bash control flow looks unfamiliar at first because conditions are commands. An if runs a command and branches on its exit code. Once you internalise that, the rest follows.
Exit Codes
true; echo $? # 0 — success
false; echo $? # 1 — failure
ls /nonexistent; echo $? # >0 — failure
Every command has an exit code. 0 means success; non-zero means failure. if branches on this directly.
if
if grep -q error /var/log/app.log; then
echo "errors found"
fi
if [ -f /etc/hosts ]; then
echo "exists"
elif [ -d /etc ]; then
echo "dir but no file"
else
echo "neither"
fi
[ is the test command — really /usr/bin/test. [[ is a Bash builtin with safer semantics.
The Test Command
[[ -f file ]] # regular file exists
[[ -d dir ]] # directory exists
[[ -e path ]] # any kind of path exists
[[ -r file ]] # readable
[[ -w file ]] # writable
[[ -x file ]] # executable
[[ -s file ]] # non-empty file
[[ -L link ]] # symbolic link
[[ -z "$VAR" ]] # empty string
[[ -n "$VAR" ]] # non-empty string
[[ "$A" == "$B" ]] # string equal
[[ "$A" != "$B" ]] # string not equal
[[ "$A" =~ ^[0-9]+$ ]] # regex match
[[ "$N" -eq 5 ]] # numeric equal
[[ "$N" -ne 5 ]] # not equal
[[ "$N" -lt 5 ]] # less than
[[ "$N" -le 5 ]] # less or equal
[[ "$N" -gt 5 ]] # greater
[[ "$N" -ge 5 ]] # greater or equal
(( N == 5 )) # alternative: arithmetic context
(( N > 5 ))
Use (( ... )) for numeric, [[ ... ]] for strings and files. Avoid [ ... ] in new code.
Combining Conditions
if [[ -f "$FILE" && -r "$FILE" ]]; then
echo "readable file"
fi
if [[ -d /tmp || -d /var/tmp ]]; then
echo "temp exists"
fi
if ! [[ -f /etc/hosts ]]; then
echo "missing"
fi
case
case "$1" in
start)
echo "starting..."
;;
stop)
echo "stopping..."
;;
restart|reload)
echo "restarting..."
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
Cleaner than nested if for matching strings or globs. Patterns can include *, ?, and character classes.
for Loops
# Over a list
for env in dev staging prod; do
echo "Deploying to $env"
done
# Over command output (PROBLEMATIC if filenames have spaces)
for f in $(ls *.log); do
echo "$f"
done
# Over an array (RECOMMENDED)
FILES=(*.log)
for f in "${FILES[@]}"; do
echo "$f"
done
# Over a range
for i in {1..10}; do
echo "$i"
done
# C-style
for ((i=0; i<10; i++)); do
echo "$i"
done
while Loops
i=0
while (( i < 5 )); do
echo "$i"
((i++))
done
# Until — opposite condition
n=10
until (( n == 0 )); do
echo "$n"
((n--))
done
# Read a file line by line (the right way)
while IFS= read -r line; do
echo "> $line"
done < input.txt
IFS= read -r is the safe form — preserves leading/trailing whitespace and backslashes. The wrong form (read line) eats both.
Pipelines and Loops
Be careful: a pipeline runs each stage in a subshell, so variables set inside the loop do not survive.
cat file.txt | while read line; do
count=$((count + 1))
done
echo "$count" # always 0 or empty
# Use process substitution instead:
count=0
while read line; do
count=$((count + 1))
done < <(cat file.txt)
echo "$count" # actually counts
Break and Continue
for i in {1..10}; do
if (( i == 5 )); then continue; fi
if (( i == 8 )); then break; fi
echo "$i"
done
A Worked Example
#!/usr/bin/env bash
set -euo pipefail
LOG_DIR="${1:-/var/log}"
DAYS="${2:-7}"
if [[ ! -d "$LOG_DIR" ]]; then
echo "Not a directory: $LOG_DIR" >&2
exit 1
fi
echo "Cleaning files older than $DAYS days in $LOG_DIR"
count=0
while IFS= read -r -d '' file; do
rm -f "$file"
((count++))
done < <(find "$LOG_DIR" -type f -name "*.log" -mtime +"$DAYS" -print0)
echo "Removed $count files"
Notes: set -euo pipefail for safety (next lesson explains), -print0 + read -d '' to handle filenames with spaces or newlines, defaults via ${1:-/var/log}.
Cert Mapping
| Cert | Scope |
|---|---|
| RHCSA / LFCS | Scripting tasks on the practical exam |
| AWS / Azure | User data, init scripts, automation runbooks |
The next lesson covers what makes the shell uniquely powerful: the pipe.