Flow Control: Looping With While/Until

4 min read

Core idea

while and until apply the same mechanism if uses — branching on a command's exit status — but repeatedly. while COMMAND; do ...; done runs the body for as long as COMMAND exits 0; until COMMAND; do ...; done runs the body for as long as COMMAND exits non-zero. The body is whatever you want to repeat, and the loop's predicate is just a command. Combined with break, continue, and the ability to pipe or redirect data into the loop, this is enough machinery to write nearly every iterative pattern shell scripts need.

Author's framing: Like if, while evaluates the exit status of a list of commands. As long as the exit status is zero, it performs the commands inside the loop.

while vs. until — same loop, opposite trigger

The two constructs are mirror images. while (( count <= 5 )) and until (( count > 5 )) produce identical behaviour with identical termination — write whichever predicate reads more naturally. In practice while is far more common; until shines when the natural language of the condition is "keep doing this until something happens" (e.g. until ping -c1 -W1 host >/dev/null; do sleep 2; done).

break exits, continue skips

Inside any loop body:

  • break terminates the current loop immediately; execution resumes after done.
  • continue skips the rest of the body for this iteration and goes back to re-evaluate the predicate.

break is essential for the "endless loop with explicit exit" idiom: while true; do ... [[ done ]] && break; done. continue shortens code that would otherwise need a nested if for every special case.

Loops read standard input — that's how you process files line by line

If you redirect a file into a loop with done < file.txt, every iteration's read consumes the next line. The loop terminates when read hits EOF and returns non-zero. This is the shell idiom for processing structured input:

while IFS=: read -r user pass uid gid name home shell; do
  echo "$user => $home"
done < /etc/passwd

IFS is set per-command, -r is on by default for safety, and the loop naturally ends at the end of file.

Piping into a loop creates a subshell — and loses the variables

cat file | while read -r line; do total=$((total+1)); done; echo "$total" will print 0, not the line count. The right side of the pipe runs in a subshell, so total is incremented in a copy of the script's environment that vanishes when the pipe closes. The fix is the same as with bare read: feed the loop via redirection (< file) or a process substitution (< <(cmd)), not a pipe.

Why it matters

Loops are where shell scripts stop being "a list of commands" and start being programs. Almost every non-trivial script has one of three shapes:

  • a while loop reading from a file,
  • a while true loop with read for a menu or REPL,
  • an until loop polling for a condition to become true.

Getting fluent with the three keeps you from reaching for higher-level languages prematurely — and from writing the kind of "ten chained pipelines with awk" one-liner that's clever to write and impossible to debug.

Loops finally make read useful

A single read is a curiosity. A read inside a while true; do ...; done is an interactive menu. The user picks an option, the script runs it, the loop continues, the menu reappears. That structure — read, dispatch, loop — is so common bash provides select as a higher-level shortcut, but while + read is the bedrock.

Loops are how you replace fragile pipelines

grep -v '^#' config | sort | uniq | head -5 | xargs -I{} do_thing {} works until "do_thing" needs context that doesn't fit in xargs. A while read -r line; do ...; done < <(grep -v '^#' config | sort | uniq | head -5) gives you a full bash environment per line — variables, conditionals, sub-commands — at the cost of two extra lines. That's a good trade once your "one-liner" exceeds 80 columns.

until makes polling readable

until curl -fs http://service/healthz >/dev/null; do sleep 2; done reads like English: "until the service is healthy, keep sleeping". The while ! ... equivalent works but reads less directly. Choose the verb that matches the intent.

Key takeaways

Mental model

Mental model

Practical application

  1. Pick the verb that matches the intent. "Keep going while X is true" → while. "Keep going until X becomes true" → until. Don't write while !.

  2. Use while read -r line; do ...; done < file for line-by-line file processing. Set IFS per-line if the format is delimited (e.g. IFS=: read -r ...).

  3. For menus, use while true; do ...; case ... esac; done with a break on the "quit" option. The infinite loop + explicit break is idiomatic, not a code smell.

  4. For polling, use until cmd; do sleep N; done with a sleep that's appropriate to the resource — 1 second for local checks, 5–10 seconds for network checks. Add a max-attempts counter so you don't poll forever.

  5. Use continue to short-circuit unwanted iterations early. while read -r line; do [[ "$line" =~ ^# ]] && continue; ...; done < file skips comments cleanly.

  6. Never pipe into a loop you need variables from. Use done < <(cmd) (process substitution) or capture the result in a variable first.

  7. Always provide a way out of while true. Either an internal break or an external signal handler. A while-true with no break is a fork bomb waiting to ship.

Example

You're writing a "wait for the database to be ready" sidecar for a docker-compose stack. The naïve approach is a fixed sleep 30 and hope. The correct shape is an until loop with a bounded attempt counter:

max_attempts=30
attempt=0

until pg_isready -h "$DB_HOST" -p 5432 -q; do
  attempt=$((attempt + 1))
  if (( attempt >= max_attempts )); then
    echo "Database not ready after ${max_attempts} attempts — giving up." >&2
    exit 1
  fi
  echo "Attempt $attempt/$max_attempts: database not ready, sleeping 2s..."
  sleep 2
done

echo "Database ready after $attempt attempts."
exec "$@"

Three things make this script good:

  1. until pg_isready is the predicate. No while !, no negation, no second-guessing what the verb means.
  2. The counter bounds the worst case. Without it the loop would poll forever if the database never came up — a debugging nightmare.
  3. exec "$@" at the end hands control to whatever the container was supposed to run, so the readiness wait is transparent to the rest of the stack.

The loop encapsulates one idea — "wait for a thing, but not forever" — and does it in seven lines of bash that any reader can follow.

Continue exploring

Tags