TechEarl

Bash While Loops: Syntax, read-line, Retry, and Common Patterns

Bash while loop reference: condition-driven iteration, reading files line by line with the subshell-scoping fix, retry-with-backoff, wait-for-service-ready, until-loop sibling, and the [[ ]] vs [ ] vs (( )) test contexts.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Bash while loop reference: read files with while IFS= read -r, retry-with-backoff, wait-for-service polling, the subshell-scoping bug fix, the until and select siblings, plus [[ ]] vs [ ] vs (( )) test contexts.

A Bash while loop runs a block of commands repeatedly as long as a test condition is true. Where the for loop iterates over a known list, while is the right choice when you DON'T know the iteration count in advance: read each line of a file, retry a command until it succeeds, wait for a service to come up, poll until a state changes. The condition is re-evaluated every pass, so the loop continues until the condition turns false (or you break out). Below is the full reference: the four most common patterns, the cat … | while read subshell-scoping bug that catches everyone once, retry-with-backoff, wait-for-ready, the until and select siblings, and the three test contexts ([ ], [[ ]], (( ))) that Bash makes you choose between.

How do I write a while loop in Bash?

A Bash while loop runs a block of commands repeatedly as long as a test condition evaluates true. The syntax is while CONDITION; do COMMANDS; done. The condition is checked BEFORE each iteration, so if it's false to begin with, the body never runs. The most common forms: while [ "$count" -lt 10 ] for numeric comparison (POSIX), while [[ "$status" == "ok" ]] for richer string and pattern tests (Bash builtin), while (( count < 10 )) for arithmetic (compact, no quoting), while IFS= read -r line for reading a file line by line, and while true for unbounded polling (exit via break). Use continue to skip to the next iteration and break to exit the loop entirely. For known iteration counts or fixed lists, prefer Bash for loops instead.

Jump to:

The four most common while-loop patterns

bash
# 1. Counter — run N times when N comes from a variable
count=1
while (( count <= 10 )); do
    echo "Iteration $count"
    (( count++ ))
done

# 2. Read each line of a file
while IFS= read -r line; do
    echo "$line"
done < file.txt

# 3. Retry until success
attempt=0
while ! curl -fsSL https://api.example.com/health; do
    (( attempt++ )) && [ "$attempt" -ge 5 ] && exit 1
    sleep 2
done

# 4. Wait for a condition to flip
while [ ! -f /tmp/ready.flag ]; do
    sleep 1
done

These four cover ~85% of real-world while-loop use. The rest of this page covers the gotchas that turn working scripts into scripts that don't break in subtle ways.

Test contexts: [ ] vs [[ ]] vs (( ))

Bash gives you three test contexts. Knowing which to use when is the difference between robust scripts and shell scripts that break on the first edge case.

ContextUse caseExample
[ ] (POSIX test)Maximum portability; works in sh as well as Bashwhile [ "$x" -lt 10 ]; do ...
[[ ]] (Bash builtin)Rich string ops, glob and regex matching, no word splitting on the LHSwhile [[ "$status" == ok* ]]; do ...
(( )) (arithmetic)Numeric comparison without quoting, C-style operatorswhile (( x < 10 )); do ...

Practical guidance:

  • Numeric: prefer (( )) — no quoting needed, supports ++, --, +=, -=, <=, >=, <, > directly.
  • String equality, pattern, regex: prefer [[ ]] — supports ==, !=, =~ (regex match), <, > (lexicographic), and globs on the right side.
  • POSIX compatibility required: stick to [ ] and quote everything ([ "$x" -lt 10 ] not [ $x -lt 10 ]).

Mixing them in one script is fine — pick the context that matches the test you're doing.

Reading a file line by line

The canonical pattern:

bash
while IFS= read -r line; do
    # process "$line"
    echo ">> $line"
done < file.txt

What every flag does:

  • IFS= (empty IFS) — preserves leading and trailing whitespace on each line. Without this, read trims them.
  • -r (raw) — disables backslash interpretation. Without -r, a \ at the end of a line escapes the newline and reads the next line as a continuation.
  • < file.txt — redirects file content as the loop's stdin.

This is the only correct way to read a file in Bash. Every other pattern (the for line in $(cat file) trap, the cat file | while subshell trap) has a known bug.

To read line-by-line from a command's output (not a file), use process substitution:

bash
while IFS= read -r line; do
    echo "Found: $line"
done < <(find . -name "*.log")

< <(command) runs the command in the parent shell's context. Variables you set inside the loop persist after the loop ends — unlike command | while read (next section).

The cat file | while read subshell-scoping bug

The #1 while-loop bug:

bash
# ❌ BAD — total stays at 0 after the loop
total=0
cat numbers.txt | while IFS= read -r line; do
    total=$(( total + line ))
done
echo "Total: $total"   # prints 0

Why: each side of a pipe runs in a subshell. Variables set inside while are scoped to that subshell and discarded when the pipe closes. The outer total is never modified.

Three fixes:

bash
# ✅ A. Process substitution (cleanest)
total=0
while IFS= read -r line; do
    total=$(( total + line ))
done < <(cat numbers.txt)
echo "Total: $total"   # works

# ✅ B. Redirect from the file directly (best when input IS a file)
total=0
while IFS= read -r line; do
    total=$(( total + line ))
done < numbers.txt

# ✅ C. lastpipe option (Bash 4.2+)
shopt -s lastpipe
total=0
cat numbers.txt | while IFS= read -r line; do
    total=$(( total + line ))
done

Option C requires set +m (job control off) to actually take effect — on macOS Bash 3.2 it doesn't work at all. Use option A or B for portable code.

Retry with exponential backoff

The pattern I reach for every time a network call needs to handle transient failures:

bash
#!/usr/bin/env bash
MAX_ATTEMPTS=8
attempt=0
delay=1

while (( attempt < MAX_ATTEMPTS )); do
    if curl -fsSL https://api.example.com/health; then
        echo "Success on attempt $((attempt + 1))"
        exit 0
    fi
    (( attempt++ ))
    echo "Attempt $attempt failed, sleeping ${delay}s..."
    sleep "$delay"
    delay=$(( delay * 2 ))   # 1, 2, 4, 8, 16, 32, 64, 128
done

echo "Failed after $MAX_ATTEMPTS attempts" >&2
exit 1

Three things this pattern gets right:

  • curl -f makes curl exit non-zero on HTTP 4xx/5xx (it returns 0 by default for any HTTP response).
  • Exponential backoff (delay *= 2) avoids hammering the server during outages. Total wait time after 8 attempts is 255 seconds.
  • exit 1 after the loop signals the calling script that the operation failed. Without it, the script silently continues.

For randomised backoff (jitter, to avoid thundering-herd on shared retries):

bash
sleep "$(( delay + (RANDOM % delay) ))"   # delay to 2*delay range

Wait until a service is ready

Common in CI/CD and Docker entrypoints — block until a database or web service accepts connections, then continue:

bash
#!/usr/bin/env bash
# Wait up to 60 seconds for PostgreSQL to accept connections
DEADLINE=$(( SECONDS + 60 ))

while ! pg_isready -h db -p 5432 -U postgres > /dev/null 2>&1; do
    if (( SECONDS >= DEADLINE )); then
        echo "Postgres did not start within 60s" >&2
        exit 1
    fi
    sleep 1
done

echo "Postgres is ready, running migrations..."
psql -h db -U postgres -f /migrations/init.sql

SECONDS is a Bash builtin that returns the number of seconds since the shell started — a free monotonic timer. The pattern: compute a deadline once, loop while the current SECONDS is below it.

Same pattern for HTTP services:

bash
DEADLINE=$(( SECONDS + 30 ))
while ! curl -fsS http://localhost:3000/health > /dev/null; do
    (( SECONDS < DEADLINE )) || { echo "Timeout" >&2; exit 1; }
    sleep 1
done

Use this in Dockerfiles, Kubernetes init containers, GitHub Actions steps, deploy scripts — anywhere one process needs to wait for another before proceeding.

break, continue, and labelled breaks

bash
# break — exit the loop entirely
while true; do
    read -p "> " input
    [ "$input" = "quit" ] && break
    echo "You typed: $input"
done

# continue — skip to the next iteration
count=0
while (( count < 10 )); do
    (( count++ ))
    (( count % 2 == 0 )) && continue   # skip even numbers
    echo "$count"
done

# break N — exit N levels of nested loop
while read -r outer; do
    while read -r inner; do
        if [ "$inner" = "STOP" ]; then
            break 2   # exit BOTH loops, not just the inner one
        fi
    done < inner.txt
done < outer.txt

break N and continue N accept a level argument. break 2 exits two loops at once; continue 2 advances the outer loop's iteration without running the rest of the inner pass. Useful in nested polling and stream-processing patterns.

Reading input with a timeout

read -t SECONDS returns an error if the user doesn't type within the deadline:

bash
while true; do
    if read -t 10 -p "> " input; then
        [ "$input" = "quit" ] && break
        echo "Got: $input"
    else
        echo "No input in 10s, exiting"
        break
    fi
done

Equivalent for stream-reading with timeout (per-line):

bash
while IFS= read -r -t 5 line; do
    echo "Got: $line"
done

When read -t times out, the loop exits cleanly. Useful for tailing a stream where you want to break out if data stops arriving.

The until and select siblings

while has two close cousins:

until — runs while the condition is FALSE (the inverse of while):

bash
# Wait for a file to appear
until [ -f /tmp/ready.flag ]; do
    sleep 1
done

# Equivalent while form:
while [ ! -f /tmp/ready.flag ]; do
    sleep 1
done

The two are interchangeable. Pick whichever reads more naturally: until for "wait for X to be true" patterns, while for everything else.

select — interactive numbered menu:

bash
PS3="Choose an action: "
select choice in "start" "stop" "restart" "quit"; do
    case "$choice" in
        start)   systemctl start myapp ;;
        stop)    systemctl stop myapp ;;
        restart) systemctl restart myapp ;;
        quit)    break ;;
        *)       echo "Invalid choice: $REPLY" ;;
    esac
done

select displays a numbered menu, reads user input into $REPLY, and sets $choice to the matched option (or empty if invalid). The loop runs until break.

For loop-choice guidance: known list or count → for; condition-driven → while; "do until success" → until; interactive menu → select. Same scoping rules, same break/continue semantics across all four.

Common pitfalls

1. The pipe subshell bug. cat file | while read x; do VAR=...; doneVAR is lost after the loop. Use done < <(cat file) or done < file instead. Covered in detail above.

2. Infinite loops from missing increment. while (( count < 10 )); do echo "$count"; done with no (( count++ )) runs forever. Always trace the variable that controls the condition.

3. while read without -r mangles backslashes. A line containing path\to\file reads as pathtofile. Always use -r.

4. while read without IFS= trims whitespace. Leading and trailing spaces in each line are silently dropped. Always set IFS= for the read.

5. Numeric comparison with the wrong operator. [ "$x" < 10 ] does a STRING comparison (then redirects, because < is also redirection). Use [ "$x" -lt 10 ] for POSIX or (( x < 10 )) for arithmetic.

6. Unbounded retry without a circuit-breaker. while ! curl ...; do sleep 1; done blocks forever if the service is permanently down. Always add a max-attempts or deadline check.

7. Disk-space regex that misses 100%. df / | awk 'NR==2 {print $5}' | grep -q '^[8-9][0-9]%' matches 80-99% but NOT 100%. Use grep -qE '^([8-9][0-9]|100)%' to include the full state.

8. while true without break. Most infinite loops should have at least one break path. Without it, the loop is unreachable to exit cleanly and you need SIGTERM to kill it.

9. Forgetting to quote in [ ]. [ $x -eq 10 ] breaks if $x is empty (syntax error: [ -eq 10 ]). Always quote: [ "$x" -eq 10 ]. Or switch to [[ ]]/(( )) which don't have this problem.

Debugging while loops

bash
# Trace every command
bash -x script.sh

# Trace one section
set -x
while ...; do
    ...
done
set +x

# Fail fast on errors and unset variables
set -euo pipefail

# Static analysis — catches every common while-loop pitfall above
shellcheck script.sh

shellcheck flags the missing -r on read, the unquoted variable in [ ], the pipe-subshell scoping issue, and most of the pitfalls listed above. Run it on every script you write. Install via brew install shellcheck on macOS or apt install shellcheck on Debian/Ubuntu.

What to do next

FAQ

TagsBashShell ScriptingLinuxCommand LineAutomationwhile loopuntil loopread
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Bash for loop reference: brace-range {1..10}, sequence (seq), array, glob, C-style, nested, parallel with xargs. Plus safe file iteration with find -print0, globbing pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ differences.

Bash For Loops: Syntax, Examples, and One-Liners

Every form of the Bash for loop with working examples: brace-range, sequence-expression, array, glob, C-style, nested, and parallel. Plus the safe file-iteration patterns, common pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ gotchas.

Bash arrays reference: declaration, indexing, [@] vs [*] quoting, iteration, appending, slicing, mapfile/readarray for lines, IFS-based string splitting, plus macOS Bash 3.2 limits.

Bash Arrays: Indexed, Associative, and Iteration Patterns

Bash array reference: indexed and associative declaration, the [@] vs [*] quoting gotcha, iterating values and indexes, appending, slicing, deleting, mapfile/readarray for reading lines, and the macOS Bash 3.2 vs Linux Bash 4+ differences.