Shell Control Flow


  • Description: if, for (list and C-style), while, until, case, select, infinite loops, and break / continue
  • My Notion Note ID: K2A-E-5
  • Created: 2020-06-03
  • Updated: 2026-05-18
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. if / elif / else

The condition is any command; "truthy" means exit status 0.

# Multi-line form
if [[ -f $config ]]; then
  source "$config"
elif [[ -f $config.default ]]; then
  source "$config.default"
else
  echo "no config" >&2
  exit 1
fi

# One-liner
if [[ $n -gt 0 ]]; then echo positive; else echo non-positive; fi
  • Syntax: then, elif, else, fi are word delimiters; the ; (or newline) before then is required.
  • The body cannot be empty — use the null command : if you want a no-op branch:
    if [[ -f $f ]]; then
      : # placeholder
    else
      touch "$f"
    fi
    
  • For short conditional commands, && / || is idiomatic:
    [[ -d /tmp/cache ]] || mkdir /tmp/cache
    command -v rg && echo "ripgrep present"
    
  • Pitfall: cmd && a || b is not an if/else. If a fails, b runs too. Use a real if when both branches must be exclusive.

2. for over a List

for fruit in apple banana cherry; do
  echo "$fruit"
done

# Iterate over a glob
for f in *.log; do
  gzip "$f"
done

# Iterate command output (be careful — see pitfalls)
for u in $(cut -d: -f1 /etc/passwd); do
  echo "$u"
done

# Iterate over an array
files=(report.md notes.md draft.md)
for f in "${files[@]}"; do
  wc -l "$f"
done
  • Glob caveat: if *.log matches nothing, the literal string *.log is iterated (default). Set shopt -s nullglob to make a no-match expand to nothing, or guard:
    shopt -s nullglob
    for f in *.log; do gzip "$f"; done
    
  • Word splitting: for u in $(cut ...) splits on $IFS. Filenames with spaces break it. Prefer arrays or while read:
    while IFS= read -r line; do
      echo "$line"
    done < file.txt
    
  • Iterate a range: brace expansion {1..10} is bash-only and not portable; for portability use C-style for.

3. C-Style for ((;;))

Bash extension. Same shape as C:

for (( i = 0; i < 10; i++ )); do
  echo "$i"
done

# Step by 2, descend
for (( i = 100; i >= 0; i -= 2 )); do
  echo "$i"
done

# Brace expansion alternative (literal, NOT a loop)
for i in {1..10}; do echo "$i"; done       # 1..10
for i in {0..9}; do echo "$i"; done        # 0..9
for i in {0..20..5}; do echo "$i"; done    # 0 5 10 15 20 (step)
  • Inside (( )): no $, full integer arithmetic, C operators.
  • Three semicolons: init, condition, step. Any can be empty (an empty condition is "true forever").
  • {a..b..step} brace expansion: literal expansion done before execution, NOT a runtime loop. Variables don't expand in the bounds: n=10; echo {1..$n} yields {1..10} literally (workaround: seq 1 "$n" or C-style for).

4. while and until

# while: run while the condition is true (exit 0)
i=0
while (( i < 5 )); do
  echo "$i"
  (( i++ ))
done

# until: run while the condition is FALSE (exit non-zero)
until ping -c1 -W1 host.local &>/dev/null; do
  echo "waiting for host..."
  sleep 1
done

# Read a file line by line (the safe idiom)
while IFS= read -r line; do
  echo "[$line]"
done < input.txt
  • The condition is any command; while loops while it's true, until loops until it becomes true.
  • IFS= read -r line is the canonical line-reader:
    • IFS= (no IFS) → don't strip leading/trailing whitespace.
    • -r → don't interpret backslashes.
    • Without these, lines with spaces or backslashes get mangled.
  • Reading from a pipe creates a subshell, so assignments inside don't leak out:
    count=0
    printf 'a\nb\n' | while read -r _; do (( count++ )); done
    echo "$count"   # still 0 — count incremented in subshell
    
    # Fix: process substitution
    count=0
    while read -r _; do (( count++ )); done < <(printf 'a\nb\n')
    echo "$count"   # 2
    

5. Infinite Loops

while :;       do …; done    # `:` is the null command, always returns 0
while true;    do …; done    # `true` is a builtin returning 0
for (( ; ; )); do …; done    # empty condition = forever

# Exit on a condition with break
while :; do
  read -rp "> " cmd
  [[ $cmd == quit ]] && break
  eval "$cmd"
done
  • All three forms are equivalent. while : is shortest; while true reads better.
  • Always have a break (or signal handler) — Ctrl-C from a terminal stops a foreground loop, but services and background scripts need an explicit exit.

6. case / esac

Pattern-based switch using globs. Closest to a typed switch in C, but with glob patterns instead of constants.

case $1 in
  start)
    start_service
    ;;
  stop|halt|quit)            # alternation with |
    stop_service
    ;;
  -v|--verbose)
    verbose=1
    ;;
  *.log)                     # glob pattern
    process_log "$1"
    ;;
  [0-9]*)                    # starts with a digit
    process_num "$1"
    ;;
  '')                        # empty
    echo "no arg" >&2
    ;;
  *)                         # default
    echo "unknown: $1" >&2
    exit 2
    ;;
esac
  • ;; ends a clause (no fallthrough by default).
  • ;& (bash 4+) — fall through to the next clause's body unconditionally.
  • ;;& (bash 4+) — continue testing remaining patterns; multiple clauses can run.
  • Patterns are globs, not regex: *, ?, [...], alternation | between patterns, and extglobs (?(p), *(p), +(p), @(p), !(p)) when shopt -s extglob is on.
  • The value being matched is NOT quoted between case and in; the patterns ARE the place to be careful. Patterns are unquoted to enable glob behavior; quote a literal substring inside a pattern to disable it: case $x in "*.log") ...;; matches the literal *.log.

7. select — Numbered Menu

Bash builtin that prints a numbered menu and reads a choice in a loop:

PS3="Pick an environment: "
select env in dev staging prod quit; do
  case $env in
    dev|staging|prod) echo "→ $env"; break ;;
    quit) break ;;
    *) echo "invalid: $REPLY" ;;
  esac
done
  • PS3 is the prompt string (default #?).
  • $REPLY holds the raw input; $env holds the matching menu item (empty on bad input).
  • Loops until break, EOF (Ctrl-D), or explicit exit. Useful for quick interactive scripts; not POSIX.

8. break and continue

for f in *.log; do
  [[ -s $f ]] || continue            # skip empty
  grep -q ERROR "$f" || continue     # skip clean
  echo "ERROR in $f"
  process "$f"
done

# Nested loops: break n exits N enclosing loops
for outer in a b c; do
  for inner in 1 2 3; do
    [[ $inner == 2 && $outer == b ]] && break 2
    echo "$outer$inner"
  done
done
# prints a1 a2 a3 b1; then `break 2` exits both loops
  • break exits the innermost loop; break N exits N levels.
  • continue skips to the next iteration of the innermost loop; continue N skips N levels.
  • return is for functions, not loops; using it inside a top-level loop exits the script (POSIX-undefined behavior).

9. References