LinuxXXXV · Shell Scripting for SysadminsConditions loops
Conditions and loops - controlling script flow
What you'll learn
- Use if/elif/else/fi
- Use case statements for multiple branches
- Use for and while loops
- Recognise common patterns
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-09
Conditions and loops are the basic control structures in bash. This lesson covers the right patterns for production scripts.
if / elif / else / fi
if [[ -f "$FILE" ]]; then
echo "File exists"
elif [[ -d "$FILE" ]]; then
echo "Directory"
else
echo "Neither"
fi
Use [[ ]] (bash builtin; more features than [ ]).
Test operators
# File tests
[[ -f "$file" ]] # is a regular file
[[ -d "$dir" ]] # is a directory
[[ -e "$path" ]] # exists
[[ -r "$file" ]] # readable
[[ -w "$file" ]] # writable
[[ -x "$file" ]] # executable
[[ -s "$file" ]] # non-empty
[[ -L "$link" ]] # is a symlink
# String tests
[[ -z "$str" ]] # empty
[[ -n "$str" ]] # non-empty
[[ "$a" == "$b" ]] # equal
[[ "$a" != "$b" ]] # not equal
[[ "$str" =~ ^foo ]] # regex match
# Numeric tests
[[ "$n" -eq 5 ]] # equal
[[ "$n" -gt 5 ]] # greater than
[[ "$n" -lt 5 ]] # less than
# Logical
[[ -f "$a" && -f "$b" ]] # both
[[ -f "$a" || -f "$b" ]] # either
[[ ! -f "$a" ]] # not
case statement
For multiple branches:
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service; start_service ;;
*) echo "Usage: $0 {start|stop|restart}" >&2; exit 1 ;;
esac
*) matches anything. Patterns use glob syntax.
for loops
# Iterate over a list
for name in alice bob carol; do
echo "Hello, $name"
done
# Iterate over files
for file in /var/log/*.log; do
echo "Processing $file"
done
# C-style for. The i++ here is part of the for construct, not a
# standalone command, so it is safe under set -e.
for ((i = 0; i < 10; i++)); do
echo "$i"
done
# Iterate over command output
while IFS= read -r line; do
echo "$line"
done < /etc/passwd
while loops
# Condition-based
count=0
while ((count < 10)); do
echo "$count"
count=$((count + 1))
done
# Until condition is true
count=0
until ((count >= 10)); do
echo "$count"
count=$((count + 1))
done
# Infinite (with break) - use a real test, not a placeholder word.
# `[[ condition ]]` is a non-empty string, so it is ALWAYS true and the
# loop breaks on the first pass. shellcheck flags it as SC2078.
while true; do
if [[ -f /var/run/job.done ]]; then
break
fi
sleep 5
done
The same trap applies to the arithmetic conditions above -
while ((count < 10)) - but there it is harmless. A command in a
while, until or if condition is exempt from set -e by
design; that is exactly how the loop terminates. The danger is
only the arithmetic command standing alone in the loop body.
Where set -e does not fire
set -euo pipefail is a backstop, not a safety net. It does not
trigger when the failing command is:
- Any command in an
if,whileoruntilcondition. - Any command joined by
&&or||, except the last one. - Any command negated with
!. - An arithmetic command such as
((i++))that evaluates to 0. - A command inside
$( )whose status is discarded by an assignment -local x=$(cmd)orexport x=$(cmd). The assignment’s status is what counts, and it is 0. - Any command in a subshell whose status is not checked,
including the left-hand side of a pipeline without
pipefail.
Check the statuses that matter explicitly. Do not assume set -e
covers them.
Common patterns
Iterate over files safely
count=0
while IFS= read -r -d '' file; do
echo "Processing $file"
count=$((count + 1))
done < <(find /var/log -name '*.log' -print0)
echo "processed $count files"
-print0 and -d '' handle filenames with spaces — and with
newlines, which is the case that actually bites, because a
newline in a filename is indistinguishable from a record
separator in line-oriented output.
The done < <(find ...) form is the part worth memorising.
The obvious way to write this is a pipeline:
find /var/log -name '*.log' -print0 | while IFS= read -r -d '' file; do
count=$((count + 1)) # incremented in a SUBSHELL
done
echo "$count" # still 0
Every stage of a pipeline runs in a subshell, so the loop body
gets its own copy of the shell’s variables. The counter goes
up inside the loop and is discarded when the loop ends. So
does an array you appended to, so does a flag you set — and an
exit 1 inside the loop exits the subshell, leaving the
script running as if nothing had failed. The script reports
success on a run that processed nothing.
Process substitution (< <(cmd)) redirects the command’s
output into the loop’s stdin without a pipeline, so the loop
runs in the current shell and the variables survive.
One thing it does not give you: the producer’s exit status.
find failing halfway through leaves the loop looking like it
simply had less work to do. If a truncated run must not pass
silently, write the output to a temp file first, check the
status, then read the file.
Skip a directory
for file in *; do
[[ -d "$file" ]] && continue
echo "File: $file"
done
Read line by line
while IFS= read -r line; do
echo "$line"
done < input.txt
IFS= prevents trimming; -r prevents escape interpretation.
Knowledge check
Knowledge check · 5 questions
Q1. What is the difference between [[ ]] and [ ]?
Q2. `for f in *` handles filenames containing spaces correctly, while `for f in $(ls)` does not.
Q3. Which of the following are valid bash test operators? Select all that apply.
Q4. A rolling-restart script starts with set -euo pipefail, sets count=0, and increments with ((count++)) inside a while loop. It restarts node1 and then exits with status 1. What actually happened?
Q5. In which of these situations does set -e NOT abort the script on a failing command? Select all that apply.
Passing score: 75%. Answers are checked in this browser.