Bash · lesson 6 of 22
Bash Control Flow
Master Bash if/elif/else, case, for, while and until loops, break and continue, with real output and the pipe-subshell and unmatched-glob traps.
By SkillByExample · Updated
Every example below is a complete script, run with bash 5.2 on Ubuntu 24.04.
if / elif / else
if runs a command and branches on its exit status. [[ ]] is just the most common
command to put there.
for score in 95 85 72 40; do
if [[ $score -ge 90 ]]; then
grade=A
elif [[ $score -ge 80 ]]; then
grade=B
elif [[ $score -ge 70 ]]; then
grade=C
else
grade=F
fi
echo "$score -> $grade"
done
$ bash grade.sh
95 -> A
85 -> B
72 -> C
40 -> F
fi closes the block. The branches are tested top to bottom and the first match wins, so
85 never reaches the -ge 70 test.
Any command is a condition
Status 0 counts as true and anything else as false. grep -q is a common choice:
printf 'INFO start\nERROR disk full\n' > app.log
if grep -q "ERROR" app.log; then
echo "errors found"
fi
if ! grep -q "FATAL" app.log; then
echo "no fatal errors"
fi
x=5
if $x == 5; then echo "equal"; fi
$ bash cmd-cond.sh
errors found
no fatal errors
cmd-cond.sh: line 10: 5: command not found
The last if is the mistake the FAQ describes. Bash expanded $x and tried to run a
program named 5. The condition then failed, so the body was skipped instead of the
script stopping. Comparisons need [[ $x -eq 5 ]] or (( x == 5 )).
For one-line guards, && and || are shorter than a full if, as in
[[ -d build ]] || mkdir build.
case Statement
case matches one value against a list of glob patterns. It is the standard way to
dispatch subcommands.
action="${1:-help}"
case "$action" in
start) echo "Starting service..." ;;
stop) echo "Stopping service..." ;;
restart|reload) echo "Restarting service..." ;;
-h|--help|help) echo "Usage: service.sh {start|stop|restart}" ;;
*)
echo "Unknown action: $action" >&2
exit 1
;;
esac
$ bash service.sh reload; bash service.sh; bash service.sh deploy; echo "exit=$?"
Restarting service...
Usage: service.sh {start|stop|restart}
Unknown action: deploy
exit=1
| joins several patterns into one branch, ;; ends a branch, and *) catches everything
else. With no argument, ${1:-help} supplies a default, so the usage line prints. Because
the patterns are globs, *.csv) and *.json) work for routing by file type.
for Loops
A for loop runs over a list of words. Globs expand into that list, but a glob that
matches nothing needs care.
touch a.log b.log
for f in *.log; do echo "log: $f"; done
for f in *.csv; do echo "csv: $f"; done
shopt -s nullglob
for f in *.csv; do echo "csv with nullglob: $f"; done
echo "done"
$ bash globs.sh
log: a.log
log: b.log
csv: *.csv
done
There are no .csv files, yet the second loop ran once, with the literal text *.csv.
By default an unmatched glob stays as written. Either guard the body with
[[ -e $f ]] || continue or set shopt -s nullglob, which makes an unmatched glob expand
to nothing, so the loop body never runs.
For numeric ranges there are three tools, and one of them has a trap:
for (( i = 1; i <= 3; i++ )); do printf 'c-style %d\n' "$i"; done
echo backup_{01..03}.tar.gz
echo $(seq 0 25 100)
n=3
echo {1..$n}
$ bash ranges.sh
c-style 1
c-style 2
c-style 3
backup_01.tar.gz backup_02.tar.gz backup_03.tar.gz
0 25 50 75 100
{1..3}
Brace expansion pads with zeros when you write 01, but it happens before variables
are expanded. So {1..$n} is never treated as a range, and it prints literally. When the
bounds come from a variable, use the C-style loop or seq.
while and the Subshell Trap
while read is the standard way to process input line by line. How you feed it input
decides whether its variable changes survive the loop.
printf 'a\nb\nc\n' > items.txt
count=0
cat items.txt | while IFS= read -r line; do
(( count++ ))
done
echo "after pipe: count=$count"
count=0
while IFS= read -r line; do
(( count++ ))
done < <(cat items.txt)
echo "after process substitution: count=$count"
$ bash subshell.sh
after pipe: count=0
after process substitution: count=3
Each part of a pipeline runs in its own subshell. The first loop counted to 3 inside that
subshell and then discarded the result. < <(command) (process substitution) keeps the
loop in the current shell. When the input is a file, done < file does the same.
IFS= read -r keeps leading and trailing spaces and backslashes exactly as they are in the
input.
Retry loop
A counter plus break is the usual way to wait for something with a limit. Here, a fake
health check succeeds on its third call:
# fake health check: fails until it has been called 3 times
health() {
local n=$(( $(cat calls 2>/dev/null || echo 0) + 1 ))
echo "$n" > calls
(( n >= 3 ))
}
max_attempts=5
attempt=1
while (( attempt <= max_attempts )); do
echo "Attempt $attempt/$max_attempts..."
if health; then
echo "Service is up!"
break
fi
(( attempt++ ))
sleep 1
done
(( attempt > max_attempts )) && { echo "gave up" >&2; exit 1; }
echo "continuing with attempt=$attempt"
$ bash retry.sh
Attempt 1/5...
Attempt 2/5...
Attempt 3/5...
Service is up!
continuing with attempt=3
In a real script, health would be curl -sf http://localhost:8080/health. After the loop,
the counter tells you which way it ended: break leaves attempt at or below the limit,
and running out leaves it one past the limit.
until Loop
until is while with the test inverted. It reads naturally for “wait until this
exists”:
( sleep 2; touch job.done ) &
until [[ -f job.done ]]; do
echo "waiting..."
sleep 0.8
done
echo "Job complete!"
$ bash until.sh
waiting...
waiting...
waiting...
Job complete!
The background job creates job.done after 2 seconds. How many “waiting…” lines you see
depends on timing. Here the loop checked 3 times at 0.8-second intervals.
break and continue
continue skips to the next iteration and break leaves the loop. Give either one a
number to act on an outer loop.
for i in {1..10}; do
(( i % 2 == 0 )) && continue
(( i > 7 )) && break
printf '%d ' "$i"
done
echo
for i in {1..3}; do
for j in {1..3}; do
[[ $i -eq 2 && $j -eq 2 ]] && break 2
printf '%s,%s ' "$i" "$j"
done
done
echo
$ bash breaks.sh
1 3 5 7
1,1 1,2 1,3 2,1
break 2 left both loops at 2,2. A plain break would only have ended the inner loop,
and the outer one would have gone on to 3,1 3,2 3,3.
select: Numbered Menus
select prints a numbered menu and reads a choice each time around the loop. It keeps
going until you break.
PS3="Environment? "
select env in development staging production quit; do
case "$env" in
development|staging|production) echo "Deploying to $env..."; break ;;
quit) echo "Cancelled"; exit 0 ;;
*) echo "Invalid option: $REPLY" ;;
esac
done
$ printf '7\n2\n' | bash menu.sh
1) development
2) staging
3) production
4) quit
Environment? Invalid option: 7
Environment? Deploying to staging...
PS3 sets the prompt. An out-of-range choice leaves $env empty, but $REPLY still
holds exactly what was typed. The input here was piped in so the run is repeatable.
Interactively you’d type 7 and then 2.
Practice
-
Make
service.shacceptSTART,Reloadand other mixed-case actions without adding more patterns.Solution
Lowercase the value in the
caseline:action="${1:-help}" case "${action,,}" in start) echo "Starting service..." ;; stop) echo "Stopping service..." ;; restart|reload) echo "Restarting service..." ;; -h|--help|help) echo "Usage: service.sh {start|stop|restart}" ;; *) echo "Unknown action: $action" >&2; exit 1 ;; esac$ bash service.sh START; bash service.sh Reload Starting service... Restarting service... -
Fix
echo {1..$n}so it prints1 2 3whenn=3, in two different ways.Solution
n=3 for (( i = 1; i <= n; i++ )); do printf '%d ' "$i"; done echo for i in $(seq 1 "$n"); do printf '%d ' "$i"; done echo$ bash p2.sh 1 2 3 1 2 3 -
Keep the
cat items.txt | while readpipe fromsubshell.sh, but makecountsurvive the loop.Solution
shopt -s lastpiperuns the last part of a pipeline in the current shell. It only works when job control is off, which is the default in scripts:shopt -s lastpipe printf 'a\nb\nc\n' > items.txt count=0 cat items.txt | while IFS= read -r line; do (( count++ )) done echo "after pipe with lastpipe: count=$count"$ bash p3.sh after pipe with lastpipe: count=3 -
Print FizzBuzz for 1-15 on one line with a C-style loop and
if/elif.Solution
for (( i = 1; i <= 15; i++ )); do if (( i % 15 == 0 )); then out=FizzBuzz elif (( i % 3 == 0 )); then out=Fizz elif (( i % 5 == 0 )); then out=Buzz else out=$i fi printf '%s ' "$out" done echo$ bash p4.sh 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzzThe
% 15test has to come first. Otherwise 15 matches% 3and printsFizz.