bash · level 3

Control Flow

if / while / for / case — and the three different test brackets ([, [[, and (() — that mean similar things and don't.

150 XP

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 bashfor 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 ifif 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 uses shopt -p option.
  • zsh for x in $arr doesn't word-split (whole array as words is built-in); bash needs "${arr[@]}" quoting.
  • case syntax 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
  • ShellCheckfree tier

    SC2086 (unquoted), SC2154 (referenced but not assigned), SC2181 ($? after if). Gold-standard for catching test-bracket bugs.

    cli
  • bats-corefree tier

    TAP-style tests for bash — assert exit codes, assert output, parameterised cases. The right way to test non-trivial control flow.

    cli
  • shfmtfree tier

    Normalises if/then placement, case spacing, and brace style. Pairs with ShellCheck for CI.

    cli