while and until Loops

A while loop repeats a block of commands for as long as a condition keeps succeeding; an until loop does the opposite, repeating for as long as a condition keeps failing. Unlike a for loop, which walks through a fixed list of items, while and until are driven by a live condition that is re-checked every time through the loop — which makes them the right tool whenever you don’t know in advance how many times you’ll need to repeat something, such as reading a file until it runs out of lines, or retrying a command until it succeeds.

Overview: How while and until Loops Work

In Bash, “condition” doesn’t mean a boolean the way it does in most programming languages — it means a command. Every command that finishes in Linux returns an exit status: a number from 0 to 255 stored by the shell in the special variable $?. By convention, 0 means success and anything non-zero means failure. This isn’t enforced by the kernel; it’s just a convention that every well-behaved program follows, and it’s the entire mechanism that while and until are built on.

A while loop runs its body as long as its condition command exits with status 0 (true). An until loop runs its body as long as its condition command exits non-zero (false) — it stops the moment the condition finally succeeds. They are mirror images of each other, and anything written with until can be rewritten with while by negating the condition (with !), and vice versa. Most Bash code uses while; until exists purely for readability in cases where “keep going until X becomes true” reads more naturally than “keep going while X is false”.

Before each pass through the loop body, Bash runs the condition command fresh, waits for it to exit, and inspects its exit status. If the condition is a builtin or keyword like [[ ... ]], no new process is created — the shell evaluates it internally. If the condition is an external command, such as ping or grep, the shell forks a new child process for every single check, which is worth remembering if you’re looping thousands of times and care about performance. Because the condition is re-evaluated every pass, the loop body is responsible for eventually making the condition false (for while) or true (for until) — forgetting to do that is the single most common bug with these loops, covered below.

One of the most important uses of while in real scripts is reading input line by line, usually with while read -r line; do ... done < file. Here the “condition” is the read builtin itself: it returns exit status 0 each time it successfully reads a line, and returns non-zero once it hits end-of-file, which naturally ends the loop. This pattern is how Bash scripts process log files, config files, and command output without needing a separate iteration construct.

Syntax

while condition-command; do
    commands
done

until condition-command; do
    commands
done
Part Meaning
while Run the body repeatedly as long as condition-command exits with status 0.
until Run the body repeatedly as long as condition-command exits with a non-zero status.
condition-command Any command or pipeline — commonly [[ expression ]], (( arithmetic )), read, or an external command like ping or grep.
do / done Delimit the loop body, the same way { } would in C-family languages.
break Exits the loop immediately, skipping any remaining iterations.
continue Skips the rest of the current iteration and jumps straight back to re-checking the condition.

Examples

Example 1: a simple counting loop

#!/usr/bin/env bash
count=1
while [[ $count -le 5 ]]; do
    echo "Count: $count"
    count=$((count + 1))
done
Count: 1
Count: 2
Count: 3
Count: 4
Count: 5

The condition [[ $count -le 5 ]] is checked before every iteration. Each pass prints the current value and then increments count by 1. Once count reaches 6, the condition becomes false and the loop stops. If the count=$((count + 1)) line were missing, count would stay at 1 forever and the loop would never end.

Example 2: reading a file line by line

#!/usr/bin/env bash
log_file="/var/log/app.log"
line_number=1

while IFS= read -r line; do
    echo "$line_number: $line"
    line_number=$((line_number + 1))
done < "$log_file"
1: 2026-08-04 09:12:01 INFO  server started on port 8080
2: 2026-08-04 09:12:03 INFO  connected to database
3: 2026-08-04 09:14:47 WARN  slow query took 812ms

The < "$log_file" redirection at the end feeds the file into the loop's standard input, and read -r line pulls one line at a time into the variable line, returning success until the file is exhausted. IFS= stops read from trimming leading and trailing whitespace, and -r stops it from treating backslashes in the file as escape characters — both are standard habits when reading real-world files.

Example 3: until loop for retry logic

#!/usr/bin/env bash
attempt=1
max_attempts=5

until ping -c 1 -W 1 "server1.example.com" &> /dev/null; do
    if (( attempt >= max_attempts )); then
        echo "Giving up after $max_attempts attempts." >&2
        exit 1
    fi
    echo "Attempt $attempt: server unreachable, retrying..."
    attempt=$((attempt + 1))
    sleep 2
done

echo "Server is up!"
Attempt 1: server unreachable, retrying...
Attempt 2: server unreachable, retrying...
Server is up!

Here until reads naturally: "keep retrying until the ping succeeds." The condition is the ping command itself — its output is thrown away with &> /dev/null since only its exit status matters. The loop body only runs while ping keeps failing, and it stops as soon as ping exits with status 0.

How It Works Step by Step

Walking through Example 2's while IFS= read -r line; do ... done < "$log_file":

  1. Bash opens /var/log/app.log for reading and connects it to the loop's standard input via the < redirection — this happens once, before the loop starts.
  2. The condition, read -r line, reads characters from that input until it hits a newline, strips the newline, and stores the result in line. If it read at least a partial line, it exits with status 0.
  3. Because the exit status is 0, Bash enters the loop body: it prints the line number and content, then increments line_number.
  4. Control returns to the top of the loop, and read -r line runs again on the next line.
  5. When there is nothing left to read, read returns a non-zero exit status, the while condition is false, and Bash exits the loop, moving on to whatever comes after done.

The same evaluate-then-branch cycle applies to every while/until loop, whether the condition is read, an arithmetic test, or an external program — check the exit status, decide whether to run the body, repeat.

Common Mistakes

Mistake 1: forgetting to update the loop variable

count=1
while [[ $count -le 5 ]]; do
    echo "Count: $count"
done

Because count is never incremented inside the body, [[ $count -le 5 ]] is true forever, and the loop never terminates — it will print Count: 1 until you interrupt it with Ctrl+C. Always double-check that something inside the loop body moves the condition toward becoming false.

count=1
while [[ $count -le 5 ]]; do
    echo "Count: $count"
    count=$((count + 1))
done

Mistake 2: piping into while read loses your variables

count=0
cat "/var/log/app.log" | while read -r line; do
    count=$((count + 1))
done
echo "Total lines: $count"
Total lines: 0

Every command in a pipeline runs in its own subshell, so the while loop here executes in a child process with its own private copy of count. Incrementing it inside the loop has no effect on the count variable in the parent shell, so after the pipeline finishes, the original count is still 0. Redirecting the file directly into the loop instead avoids the subshell entirely:

count=0
while read -r line; do
    count=$((count + 1))
done < "/var/log/app.log"
echo "Total lines: $count"

Mistake 3: leaving a variable unquoted in a test

status=""
while [ $status != "running" ]; do
    echo "waiting..."
    status="running"
done

When status is empty, the unquoted $status disappears entirely after word-splitting, leaving [ != "running" ] — a malformed test that Bash reports as an error (unary operator expected) instead of behaving as intended. Quoting the expansion, and preferring [[ ]] which doesn't word-split unquoted variables in the first place, avoids this:

status=""
while [[ "$status" != "running" ]]; do
    echo "waiting..."
    status="running"
done

Best Practices

  • Prefer [[ ]] over [ ] for conditions in Bash scripts — it doesn't word-split or glob unquoted variables and supports &&/|| directly inside the brackets.
  • Always quote variable expansions inside conditions and loop bodies ("$line", "$status") to avoid word-splitting surprises.
  • Use while IFS= read -r line; do ... done < "$file" to process a file, not cat file | while read line; do ... done — it avoids an unnecessary subshell and an unnecessary cat process.
  • For a deliberate infinite loop (a long-running daemon or monitor script), write while true; do ... done and use break to exit on a real condition, rather than crafting an always-true numeric test.
  • When retrying a flaky operation, always cap the number of attempts (as in Example 3) so a persistent failure doesn't retry forever.
  • Use break and continue sparingly and only when they make the logic clearer than restructuring the condition — overusing them can make a loop's exit behavior hard to follow.

Practice Exercises

  • Write a script called countdown.sh that starts at 10 and counts down to 1 using a while loop, printing each number, then prints Liftoff!. Don't forget chmod +x countdown.sh before running it.
  • Write a script that reads /etc/hosts line by line with a while read loop and prints only the lines that don't start with # (hint: use [[ $line == \#* ]] to test for a leading #, and continue to skip such lines).
  • Write a script that uses an until loop to wait for a file named /tmp/ready.flag to appear, checking every 2 seconds with sleep 2, and printing Ready! once [[ -f /tmp/ready.flag ]] becomes true. Test it by creating the file in another terminal with touch /tmp/ready.flag.

Summary

  • while repeats its body as long as the condition command exits with status 0; until repeats as long as the condition exits non-zero.
  • The condition is re-evaluated fresh before every iteration — the loop body must eventually change the outcome, or the loop runs forever.
  • while IFS= read -r line; do ... done < "$file" is the standard, subshell-free way to process a file line by line in Bash.
  • Piping into while read runs the loop in a subshell, so variables set inside it are lost once the pipeline ends — redirect from the file instead.
  • Always quote variable expansions and prefer [[ ]] over [ ] to avoid word-splitting bugs in conditions.
  • break exits a loop immediately; continue skips straight to the next condition check.