Shell Control Flow
- Description:
if,for(list and C-style),while,until,case,select, infinite loops, andbreak/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 - 2.
forover a List - 3. C-Style
for ((;;)) - 4.
whileanduntil - 5. Infinite Loops
- 6.
case/esac - 7.
select— Numbered Menu - 8.
breakandcontinue - 9. References
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,fiare word delimiters; the;(or newline) beforethenis 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 || bis not an if/else. Ifafails,bruns too. Use a realifwhen 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
*.logmatches nothing, the literal string*.logis iterated (default). Setshopt -s nullglobto 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 orwhile 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-stylefor.
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-stylefor).
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;
whileloops while it's true,untilloops until it becomes true. IFS= read -r lineis 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 truereads 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)) whenshopt -s extglobis on. - The value being matched is NOT quoted between
caseandin; 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
PS3is the prompt string (default#?).$REPLYholds the raw input;$envholds 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
breakexits the innermost loop;break Nexits N levels.continueskips to the next iteration of the innermost loop;continue Nskips N levels.returnis for functions, not loops; using it inside a top-level loop exits the script (POSIX-undefined behavior).