Skip to main content
Bash · lesson 6 of 22
Bash beginner 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

  1. Make service.sh accept START, Reload and other mixed-case actions without adding more patterns.

    Solution

    Lowercase the value in the case line:

    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...
  2. Fix echo {1..$n} so it prints 1 2 3 when n=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 
  3. Keep the cat items.txt | while read pipe from subshell.sh, but make count survive the loop.

    Solution

    shopt -s lastpipe runs 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
  4. 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 FizzBuzz 

    The % 15 test has to come first. Otherwise 15 matches % 3 and prints Fizz.

Frequently Asked Questions

Why does my if statement fail with 'command not found'?
The condition in an if statement must be a command that returns an exit code. If you write if $x == 5, bash tries to run the value of $x as a command. Use if [[ $x -eq 5 ]] instead.
What is the difference between for x in list and for (( )) loops?
for x in list iterates over words or array elements. The C-style for (( i=0; i<10; i++ )) loop iterates by incrementing a counter — useful when you need the index.
When should I use while vs until?
while runs as long as the condition is true. until runs as long as the condition is false (i.e., until it becomes true). They are logical inverses. Most people use while exclusively.
Why is my counter zero after a while-read loop?
If the loop is on the right side of a pipe (cmd | while read ...), bash runs it in a subshell, and every variable it changes is thrown away when the pipe ends. Feed the loop with process substitution instead: while read ...; done < <(cmd), or enable shopt -s lastpipe.