04 — Control Flow Internals, Loop Patterns & Exit Status Mechanics
if: It's All About Exit Status
bash
# ── if runs the `then` block if the command's exit status is 0 (success) ──
# ANY command works — not just [[ ]] or [ ]:
if grep -q "error" logfile.txt; then # grep -q: exit 0 if match, 1 if no match, 2 on error
echo "found errors" # runs if grep exits 0
fi
if cd "$dir" 2>/dev/null; then # cd: exit 0 if dir exists, non-zero otherwise
echo "now in $dir" # 2>/dev/null suppresses "No such file" error
fi
if ping -c 1 -W 2 "$host" &>/dev/null; then # ping: exit 0 if host responds
echo "host is up"
fi
if command -v docker &>/dev/null; then # command -v: exit 0 if command exists
echo "docker available"
fi
# ── elif chains ──
if [[ $status -eq 200 ]]; then
echo "OK"
elif [[ $status -ge 400 && $status -lt 500 ]]; then
echo "client error"
elif [[ $status -ge 500 ]]; then
echo "server error"
else
echo "unknown: $status"
fi
# ── Negating a condition ──
if ! grep -q "ok" file.txt; then # ! negates the exit status
echo "no ok found"
fi
&& / ||: Short-Circuit Composition
bash
# ── && runs the right side ONLY if the left side succeeded ──
# ── || runs the right side ONLY if the left side failed ──
[[ -f config.txt ]] && echo "config exists"
[[ -f config.txt ]] || { echo "config missing" >&2; exit 1; }
# ── Chaining (all must succeed) ──
mkdir -p "$dir" && cd "$dir" && touch marker.txt && echo "setup complete"
# ── Guard pattern: check then act ──
[[ -n "$API_KEY" ]] || { echo "API_KEY required" >&2; exit 1; }
# ── Don't overuse — for complex logic, if/then is clearer ──
# ❌ HARD TO READ
[[ $x -gt 0 ]] && [[ $y -gt 0 ]] && echo "both positive" || echo "at least one non-positive"
# (the || binds to the LAST && — if the echo succeeds, || won't fire. Subtle!)
# ✅ CLEAR
if ((x > 0 && y > 0)); then
echo "both positive"
else
echo "at least one non-positive"
fi
# ── The || { ... } guard is a production idiom ──
cd "$BUILD_DIR" || { echo "can't cd to $BUILD_DIR" >&2; exit 1; }
# If cd fails, the || block runs: print error, exit. If cd succeeds, skip.
# Note: use { } (command group, same shell) not ( ) (subshell — exit would only exit the subshell!)
for Loops: List and C-Style
bash
# ── for list: iterate over words ──
for fruit in apple banana cherry; do
echo "$fruit"
done
# ── for over a glob (safe — no word splitting issues) ──
for file in *.txt; do
echo "processing $file"
done
# ⚠️ With nullglob off: if no .txt files, the loop runs ONCE with file="*.txt" (literal).
# Fix: shopt -s nullglob (empty result) or check inside:
for file in *.txt; do
[[ -e $file ]] || continue # skip if no match (glob passed literally)
echo "processing $file"
done
# ── for over array (ALWAYS quote [@]) ──
files=("my file.txt" "other file.txt" "third.txt")
for file in "${files[@]}"; do # [@] quoted: each element is a separate word
echo "$file"
done
# ── for over args ──
for arg in "$@"; do # "$@" quoted: each positional arg is a separate word
echo "arg: $arg"
done
# ── C-style for (arithmetic) ──
for ((i = 0; i < 5; i++)); do
echo "i=$i"
done
# ── C-style with multiple variables ──
for ((i = 0, j = 10; i < 5; i++, j--)); do
printf '%d %d\n' "$i" "$j"
done
# ── C-style with step ──
for ((i = 0; i < 100; i += 10)); do
echo "i=$i" # 0 10 20 30 40 50 60 70 80 90
done
# ── Infinite loop with break ──
for ((;;)); do
((count++))
((count >= 100)) && break
done
while and until
bash
# ── while: loop while condition is true (exit 0) ──
count=0
while ((count < 5)); do
echo "count=$count"
((count++))
done
# ── until: loop while condition is false (exit non-zero) ──
# (until is just while with inverted condition)
until ping -c 1 "$host" &>/dev/null; do
echo "waiting for $host..."
sleep 1
done
echo "$host is up"
# ── while with command condition (poll until success) ──
while ! curl -s "$health_url" &>/dev/null; do
echo "waiting for health check..."
sleep 2
done
echo "healthy"
# ── while with command condition (read until EOF) ──
while read -r line; do # read returns non-zero at EOF → loop exits
echo "$line"
done < file.txt
while IFS= read -r: The Canonical Line Reader
bash
# ── Every part is load-bearing ──
# IFS= → don't trim leading/trailing whitespace (default IFS=space/tab/newline trims)
# read → read one line from stdin
# -r → don't interpret backslashes (without -r, \t becomes tab, \\ becomes \)
# line → variable to store the line
while IFS= read -r line; do
printf '%s\n' "$line"
done < file.txt
# ── Why IFS= matters ──
# Input: " hello world "
# With IFS= (empty): line=" hello world " (preserved)
# Without IFS= (default): line="hello world" (leading/trailing whitespace trimmed)
# ── Why -r matters ──
# Input: "path\to\file"
# With -r: line="path\to\file" (literal backslashes)
# Without -r: line="pathtofile" (backslashes stripped, \t → tab)
# ── Reading multiple fields per line ──
while IFS=, read -r name age city; do
# IFS=, sets the delimiter for THIS read only (not global)
echo "$name ($age) from $city"
done < data.csv
# CSV: "Alice,30,NYC" → name=Alice age=30 city=NYC
# Last var gets the rest of the line (including any commas): "Alice,30,NYC,USA" → city="NYC,USA"
# ── Reading from a command (process substitution — avoids subshell trap) ──
while IFS= read -r line; do
echo "$line" # runs in MAIN shell — variables persist
done < <(grep "error" log.txt)
# ── Reading with a different delimiter (null-delimited, for find -print0) ──
while IFS= read -r -d '' file; do
# -d '' sets the delimiter to null byte (NUL)
# This is the ONLY safe way to handle filenames with spaces, newlines, or special chars
echo "found: $file"
done < <(find . -name '*.py' -print0)
# ── Reading from a heredoc ──
while IFS= read -r line; do
echo "processing: $line"
done <<EOF
first line
second line
third line
EOF
# ── Skipping blank lines and comments ──
while IFS= read -r line; do
[[ -z "$line" ]] && continue # skip blank lines
[[ "$line" =~ ^[[:space:]]*# ]] && continue # skip comments
echo "$line"
done < config.txt
Anti-Pattern: for line in $(cat file)
bash
# ❌ NAIVE — for over cat output (word-splits on whitespace, breaks on spaces)
for line in $(cat file.txt); do
echo "$line"
done
# "hello world" becomes two iterations: "hello" and "world"
# Also forks a cat subprocess (unnecessary).
# ✅ CORRECT — while read (line-by-line, no splitting)
while IFS= read -r line; do
echo "$line"
done < file.txt
# No subprocess, no word splitting, backslashes preserved, whitespace preserved.
# ── Why for $(cat) fails ──
# $(cat file.txt) captures all output as ONE string.
# for ... in <string> splits the string on IFS (whitespace by default).
# So "hello world\nfoo" becomes three words: "hello" "world" "foo".
# Even with IFS=$'\n', it still forks cat unnecessarily.
The Pipe-into-While Subshell Trap
bash
# ❌ NAIVE — variables set in while-after-pipe are lost (subshell!)
total=0
echo -e "1\n2\n3" | while read -r num; do
total=$((total + num)) # set in SUBSHELL (the right side of | runs in a subshell)
done
echo "total=$total" # total=0 — subshell's changes didn't propagate!
# ✅ CORRECT — process substitution avoids the subshell
total=0
while read -r num; do
total=$((total + num)) # set in MAIN shell
done < <(echo -e "1\n2\n3") # <() feeds stdin WITHOUT creating a subshell for the while body
echo "total=$total" # total=6 — correct!
# ✅ ALTERNATIVE — here-string (for short input)
total=0
while read -r num; do
total=$((total + num))
done <<< "$(echo -e '1\n2\n3')"
echo "total=$total" # total=6 — correct
# ── Why the pipe creates a subshell ──
# In `cmd1 | cmd2`, cmd2 runs in a subshell (a fork of the main shell).
# Any variable assignments in cmd2 (the while loop) are lost when the subshell exits.
# Process substitution <(...) provides cmd1's output as a file descriptor —
# the while loop stays in the main shell and reads from that fd.
case: Glob-Based Dispatch
bash
# ── case matches the first matching glob pattern ──
case "$1" in
start)
echo "starting..."
;;
stop)
echo "stopping..."
;;
restart|reload) # | = alternation (match either)
echo "restarting..."
;;
--*)
echo "long option: $1" # --* matches anything starting with --
;;
*)
echo "usage: $0 {start|stop|restart}" >&2
exit 1
;;
esac
# ── Patterns are GLOBS, not regex ──
case "$file" in
*.jpg|*.png|*.gif) echo "image: $file" ;; # glob: * matches any chars
[A-Z]*) echo "uppercase start" ;; # [A-Z] char class
*) echo "other" ;;
esac
# ── Capturing subpatterns: case doesn't support capture groups ──
# Use [[ =~ ]] with BASH_REMATCH for capture groups:
if [[ $1 =~ ^--([^=]+)=(.*)$ ]]; then
key="${BASH_REMATCH[1]}" # e.g. "output" from "--output=file.txt"
value="${BASH_REMATCH[2]}" # e.g. "file.txt"
echo "key=$key value=$value"
fi
# ── Fall-through: ;;& (test next pattern) vs ;& (run next body) ──
# Bash 4+ has ;;& and ;& for fall-through:
case "$x" in
*.txt)
echo "text file"
;;& # ;;& — continue testing patterns below
*.gz)
echo "gzipped"
;;
esac
# If x="file.txt.gz": prints "text file" then "gzipped" (both matched with ;;&)
case "$x" in
*.txt)
echo "text file"
;& # ;& — fall through to next body WITHOUT testing pattern
*.gz)
echo "gzipped" # always runs if *.txt matched (no re-test)
;;
esac
# If x="file.txt": prints "text file" then "gzipped" (fell through without matching *.gz)
# ── Subcommand dispatch pattern ──
case "${1:-help}" in
build) shift; cmd_build "$@" ;;
test) shift; cmd_test "$@" ;;
deploy) shift; cmd_deploy "$@" ;;
help|-h|--help) usage ;;
*) echo "unknown: $1" >&2; usage; exit 1 ;;
esac
break and continue
bash
# ── break: exit the loop ──
for i in {1..10}; do
((i == 5)) && break # stop at 5
echo "$i"
done
# prints: 1 2 3 4
# ── continue: skip to next iteration ──
for i in {1..10}; do
((i % 2 == 0)) && continue # skip even numbers
echo "$i"
done
# prints: 1 3 5 7 9
# ── break N: break out of N nested loops ──
for i in {1..3}; do
for j in {1..3}; do
((j == 2)) && break 2 # break out of BOTH loops
echo "$i $j"
done
done
# prints: 1 1 (breaks on j=2, exits both loops)
# ── continue N: continue the Nth outer loop ──
for i in {1..3}; do
for j in {1..3}; do
((j == 2)) && continue 2 # skip to next i iteration
echo "$i $j"
done
done
# prints: 1 1, 2 1, 3 1 (on j=2, skips to next i)
select: Interactive Menus
bash
# ── select generates a numbered menu, reads choice into the variable ──
PS3="Choose a fruit: " # PS3 is the prompt for select (like PS1 for shell prompt)
select fruit in apple banana cherry "quit"; do
case "$fruit" in
apple|banana|cherry)
echo "you chose $fruit"
;;
quit)
break # select loops until break
;;
*)
echo "invalid: $REPLY" # $REPLY is the raw input (the number or text)
;;
esac
done
# ── select with an array ──
options=("Build" "Test" "Deploy" "Quit")
PS3="> "
select opt in "${options[@]}"; do
case "$opt" in
Build|Test|Deploy) echo "running $opt..." ;;
Quit) break ;;
*) echo "invalid choice" ;;
esac
done
# ── select reads from stdin — redirect for non-interactive use ──
select opt in build test deploy; do
echo "$opt"
break
done <<< "build" # feeds "build" as input → opt=build
Exit Status: The Heart of Bash Control Flow
bash
# ── Every command returns an exit status (0-255) ──
# 0 = success, 1-255 = failure
# $? = exit status of the last command (SAVE IT IMMEDIATELY — it's overwritten by next command)
grep "error" log.txt
status=$? # save immediately — any command overwrites $?
if ((status == 0)); then
echo "found"
elif ((status == 1)); then
echo "not found"
elif ((status == 2)); then
echo "grep error"
fi
# ── Exit codes convention ──
# 0 success
# 1 general failure
# 2 misuse / usage error (shell builtin convention)
# 126 command found but not executable
# 127 command not found
# 128 invalid exit argument (exit 300 → 44, modulo 256)
# 128+N killed by signal N:
# 130 = 128+2 (SIGINT, Ctrl-C)
# 137 = 128+9 (SIGKILL)
# 143 = 128+15 (SIGTERM)
# ── `exit` wraps to 0-255 ──
exit 300 # actual exit code: 44 (300 % 256 = 44)
exit -1 # actual exit code: 255 (-1 % 256 = 255)
# ── Functions: `return` (not `exit`) ──
check_file() {
[[ -f "$1" ]] && return 0 || return 1
# return is like exit but for functions — returns to the caller
# return 0 = success, return 1-255 = failure
# return without a value uses the last command's exit status
}
# ── Command lists propagate exit status ──
# The exit status of a command list is the LAST command's status:
false; true # exit 0 (true is last)
true; false # exit 1 (false is last)
# ── `!` negates exit status ──
! false # exit 0 (negated 1 → 0)
! true # exit 1 (negated 0 → 1)
Production Pattern: Retry with Backoff
bash
# ── Retry a command with exponential backoff ──
retry() {
local max_attempts=$1 delay=$2 shift 2
local attempt=1
local status=0
while ((attempt <= max_attempts)); do
"$@" && return 0 # success — return 0
status=$? # capture failure status
log WARN "attempt $attempt/$max_attempts failed (exit $status)"
((attempt < max_attempts)) && sleep "$delay"
((delay *= 2)) # exponential backoff: 1, 2, 4, 8, ...
((delay > 60)) && delay=60 # cap at 60s
((attempt++))
done
return "$status" # return the last failure's exit status
}
# Usage: retry 5 1 curl -s "$url" -o "$file"
# Retries up to 5 times with backoff: 1s, 2s, 4s, 8s, 16s
💡 Tips & Tricks
bash
# ── `while true` for infinite loops with a break condition ──
while true; do
response=$(curl -s "$url")
[[ "$response" == *"ready"* ]] && break
sleep 5
done
# ── Process all files with null-delimited find (handles ALL filenames) ──
while IFS= read -r -d '' file; do
process "$file"
done < <(find . -type f -print0)
# ── Loop with index using C-style for ──
arr=("a" "b" "c")
for ((i = 0; i < ${#arr[@]}; i++)); do
printf '%d: %s\n' "$i" "${arr[i]}"
done
# ── Loop with index and value (for in + !array[@]) ──
for i in "${!arr[@]}"; do # ${!arr[@]} = indices of array
printf '%d: %s\n' "$i" "${arr[i]}"
done
# ── Batch processing with chunked iteration ──
items=($(seq 1 100))
batch_size=10
for ((i = 0; i < ${#items[@]}; i += batch_size)); do
batch=("${items[@]:i:batch_size}") # slice the array
printf 'batch: %s\n' "${batch[*]}"
done
⚠️ Edge Cases & Gotchas
bash
# ── `for f in *.txt` passes literal "*.txt" if no match ──
# Without nullglob: for f in *.txt → f="*.txt" (literal, one iteration)
# With nullglob: for f in *.txt → zero iterations (empty result)
# With failglob: for f in *.txt → bash: no match: *.txt (error, script aborts)
# Choose: nullglob (safe, no iteration) for loops. failglob for one-shot globs.
# ── `until` is `while not` (easy to confuse) ──
# while condition → loop while TRUE
# until condition → loop while FALSE (i.e., until it becomes true)
until [[ -f /tmp/ready ]]; do sleep 1; done # wait until file appears
# ── `exit` in a subshell doesn't exit the script ──
echo "before"
(echo "in subshell"; exit 1) # subshell exits 1, but...
echo "after" # prints! The main script continues.
# To propagate: check the subshell's status:
( exit 1 ) || { echo "subshell failed" >&2; exit 1; }
# ── `$?` is volatile — save it immediately ──
cmd
status=$? # ← save NOW
echo "status: $status"
other_cmd
echo "$?" # this is other_cmd's status, NOT cmd's!
# ── `while read` without IFS= and -r (silent data corruption) ──
# ❌ for line in $(cat file) — word splits, breaks on spaces
# ❌ while read line — trims whitespace (IFS=default), interprets backslashes (no -r)
# ✅ while IFS= read -r line — preserves everything (canonical)
# ── case patterns are globs, not regex ──
case "$str" in
.*\.txt) echo "hidden txt" ;; # glob: . (literal dot) * (any chars) .txt
*) echo "other" ;;
esac
# For regex matching, use [[ $str =~ regex ]], not case.
🧠 Quick Quiz
Why does this loop never end?
bash
i=0
while ((i < 10)); do
echo "$i"
done
Answer
i is never incremented inside the loop. ((i < 10)) is always true (0 < 10) because i stays 0 forever. Infinite loop.
Fix: increment i inside the loop body:
bash
i=0
while ((i < 10)); do
echo "$i"
((i++)) # ← missing increment
done
The lesson: while checks the condition each iteration, but if the condition's variables never change, the loop never exits. Always ensure the loop body modifies the condition variable (or has a break).