Control Flow
if / while / for / case — and the three different test brackets ([, [[, and (() — that mean similar things and don't.
Control Flow
Bash gives you if, while, until, for, and case — and three different bracket constructs that look similar but evaluate differently. Pick the wrong one and your conditions silently lie.
The three test brackets
| Form | Use it for | bash-only? | Notes |
|---|---|---|---|
[ expr ] |
POSIX strings, files | no | the test builtin; quirky, fragile |
[[ expr ]] |
strings, files in bash | yes | safer, supports =~, &&, < |
(( expr )) |
arithmetic | yes | C-style; no $ needed inside |
The rule: use [[ ]] for strings and files, (( )) for arithmetic, never [ ] unless you're targeting POSIX sh.
# WRONG — old POSIX [ ]
if [ $name = "Sam" ]; then ... # ❌ unquoted $name word-splits;
# if name="" you get a syntax error
# RIGHT — bash [[ ]]
if [[ $name == "Sam" ]]; then ... # ✅ no word-splitting inside [[ ]]
# so unquoted is OK (still good practice to quote)
# Arithmetic — (( ))
if (( count > 5 )); then ... # ✅ no $ needed; integer comparison
if [[ $count -gt 5 ]]; then ... # ✅ also OK; -gt is integer in [[ ]]
[[ ]] operators worth knowing:
==/!=— string equality (right side is a glob pattern unless quoted!)</>— string comparison (lexicographic)=~— regex match (groups in${BASH_REMATCH[@]})-z/-n— empty / non-empty-f/-d/-e— is file / is dir / exists-r/-w/-x— readable / writable / executable-eq/-ne/-lt/-gt— integer comparison
Note: [[ $name == s* ]] is a glob pattern match. Quote the right side to make it literal: [[ $name == "s*" ]].
if/elif/else
if [[ $count -gt 5 ]]; then
echo "many"
elif [[ $count -gt 0 ]]; then
echo "some"
else
echo "none"
fi
The condition is any command. If it exits 0, the then branch runs. [[ ]], (( )), and [ ] are commands too — they exit 0 when the expression is true.
if grep -q "ERROR" log.txt; then
echo "found error"
fi
grep -q (quiet, exit 0 on match) is idiomatic shell for "did this match anything?".
while / until
Loop while a condition is true (while) or until it becomes true (until):
while (( retries < 5 )); do
if curl -fsS "$url"; then break; fi
(( retries++ ))
sleep 2
done
until [[ -f /tmp/done ]]; do
sleep 1
done
The canonical idiom for line-by-line file reading:
while IFS= read -r line; do
echo "got: $line"
done < input.txt
IFS= keeps leading/trailing whitespace on each line. -r disables backslash-escape interpretation. < input.txt redirects the file as the loop's stdin. This is the only correct way to read a file in bash — for line in $(cat file) word-splits on whitespace, not newlines, and breaks on lines with spaces.
for — two flavours
# Foreach — iterate a list of words.
for arg in "$@"; do
echo "got: $arg"
done
for f in *.log; do
process "$f"
done
# C-style — numeric range.
for ((i=0; i<10; i++)); do
echo "$i"
done
The C-style for uses (( )) semantics — variables don't need $, and the form is for (( init; cond; step )).
case — pattern dispatch
The cleanest way to dispatch on a string value:
case "$action" in
start|begin)
start_service
;;
stop|halt)
stop_service
;;
status)
print_status
;;
*.log)
process_log "$action"
;;
*)
echo "unknown: $action" >&2
exit 64
;;
esac
Patterns are globs, not regex. * matches anything, ? matches one char, [abc] is a class, | alternates. The *) at the end is the default case — always include it.
bash 4+ extends case with explicit fall-through:
case "$severity" in
fatal) page_oncall ;& # ;& falls through to next pattern
error) increment_counter ;;& # ;;& continues testing remaining patterns
*) log_event ;;
esac
;& falls through unconditionally; ;;& re-tests the remaining patterns. Useful but rare; most cases want ;; (terminate).
Exit codes
Every command has an exit code. By convention:
| Code | Meaning |
|---|---|
0 |
success |
1 |
generic failure |
2 |
misuse of command |
64 |
EX_USAGE — command-line parse error |
126 |
found but not executable |
127 |
command not found |
128 + N |
killed by signal N (SIGINT = 130, SIGTERM = 143, SIGKILL = 137) |
The previous command's exit is in $?. Don't read it after if — if cmd; then echo "$?"; fi always shows 0 because cmd succeeded. Capture eagerly:
cmd
rc=$?
if (( rc != 0 )); then
echo "failed: $rc" >&2
fi
Or, much more commonly, just write if cmd; then ... else ... fi.
Short-circuit && and ||
mkdir -p "$dir" && cd "$dir" # only cd if mkdir succeeded
ping -c1 host || echo "host down" # echo only on ping failure
A && B runs B only if A exits 0. A || B runs B only if A exits non-zero.
The classic footgun is the &&...|| ternary:
[[ $x -gt 5 ]] && echo "big" || echo "small"
Looks like x > 5 ? "big" : "small". It isn't. If the echo "big" itself fails (returns non-zero), the || echo "small" runs too — and you print both. Use if/else for ternary semantics.
set -e and control flow
set -e (abort on error) has surprising exemptions:
- The condition of
if,while,until. - The left side of
&&/||. - The body of a function called via
||. - A pipeline (only the last command's exit matters — that's why you need
pipefail).
So:
set -e
my_func() { false; echo "this still runs"; }
my_func || echo "my_func failed" # the `false` doesn't abort — function called via ||
Plan accordingly. The "Unofficial Bash Strict Mode" (set -euo pipefail) is necessary but not sufficient.
Common bugs
[[ $name == s* ]] matches the literal string s*. Wrong — without quotes, the right side is a glob pattern. To match literal: [[ $name == "s*" ]].
[ ] with unquoted variables. [ $name = "Sam" ] breaks if $name is empty. Use [[ $name == "Sam" ]] instead, or always quote: [ "$name" = "Sam" ].
if cmd; then echo "$?"; fi always prints 0. $? is the exit of the last command, which inside then is cmd (which succeeded — that's why we're in then). Capture before the if.
while read line; do ... done < file consumes stdin. If commands inside the loop also read stdin, they'll fight over it. Use < file carefully; read -u 9 < file (with exec 9< file) gives you a private fd.
Bash vs zsh — small but real differences
- zsh's
[[ ]]is essentially the same as bash's, with a few extra glob patterns inside. - zsh has
if [[ -o option ]]to test if a shell option is set; bash usesshopt -p option. - zsh
for x in $arrdoesn't word-split (whole array as words is built-in); bash needs"${arr[@]}"quoting. casesyntax is identical.
For control flow, the gap is small. Stay with bash conventions and your scripts will run on both.
Tools in the wild
3 tools- cliShellCheckfree tier
SC2086 (unquoted), SC2154 (referenced but not assigned), SC2181 ($? after if). Gold-standard for catching test-bracket bugs.
- clibats-corefree tier
TAP-style tests for bash — assert exit codes, assert output, parameterised cases. The right way to test non-trivial control flow.
- clishfmtfree tier
Normalises if/then placement, case spacing, and brace style. Pairs with ShellCheck for CI.