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:
breakterminates the current loop immediately; execution resumes afterdone.continueskips 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
whileloop reading from a file, - a
while trueloop withreadfor a menu or REPL, - an
untilloop 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
Practical application
-
Pick the verb that matches the intent. "Keep going while X is true" →
while. "Keep going until X becomes true" →until. Don't writewhile !. -
Use
while read -r line; do ...; done < filefor line-by-line file processing. SetIFSper-line if the format is delimited (e.g.IFS=: read -r ...). -
For menus, use
while true; do ...; case ... esac; donewith abreakon the "quit" option. The infinite loop + explicit break is idiomatic, not a code smell. -
For polling, use
until cmd; do sleep N; donewith 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. -
Use
continueto short-circuit unwanted iterations early.while read -r line; do [[ "$line" =~ ^# ]] && continue; ...; done < fileskips comments cleanly. -
Never pipe into a loop you need variables from. Use
done < <(cmd)(process substitution) or capture the result in a variable first. -
Always provide a way out of
while true. Either an internalbreakor 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:
until pg_isreadyis the predicate. Nowhile !, no negation, no second-guessing what the verb means.- The counter bounds the worst case. Without it the loop would poll forever if the database never came up — a debugging nightmare.
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.
Related lessons
Related concepts
- Loopslinked concept
- Control Flowlinked concept
- Exit Codeslinked concept
- Bash Scriptinglinked concept
- Input Validationlinked concept