Loops & Control Flow

Every loop form bash offers, the arithmetic and C-style variants people forget, and the iteration mistakes that quietly process the wrong things.

beginner 16 min lesson hands-on task included

A loop is easy to write and easy to write wrongly. The forms below cover every case you will meet, and the failure modes are the same three repeated.


Topic 1: for — Iterating a List

for item in one two three; do
    echo "$item"
done

The list is produced by the shell’s normal expansions, which is where both the power and the bugs come from.

Globs — the safe default:

for f in /var/log/*.log; do
    echo "$f"
done

A glob never word-splits, so a filename containing spaces stays one item. This is the correct way to iterate files.

Enable nullglob so an empty directory runs zero iterations rather than once with the literal pattern:

shopt -s nullglob
for f in /empty/*.log; do echo "$f"; done      # runs 0 times

Without it, $f is literally /empty/*.log — a file that does not exist, which then fails downstream in a confusing way.

Arrays:

for host in "${hosts[@]}"; do        # quoted [@] -- element boundaries preserved
    ssh -n "$host" uptime
done

Positional parameters:

for arg in "$@"; do echo "$arg"; done
for arg; do echo "$arg"; done         # identical -- "in $@" is the default

Omitting the in clause iterates "$@" correctly, including quoting. It is the shortest safe form.

Ranges and sequences:

for i in {1..10}; do echo "$i"; done          # brace expansion
for i in {0..20..5}; do echo "$i"; done       # step of 5 → 0 5 10 15 20
for i in {a..e}; do echo "$i"; done           # a b c d e
for i in $(seq 1 10); do echo "$i"; done      # seq: forks, but takes variables

Brace expansion happens before parameter expansion, so {1..$n} does not work — it expands literally. Use seq "$n" or the C-style loop when the bound is a variable.

C-style:

for (( i = 0; i < 10; i++ )); do
    echo "$i"
done

for (( i = ${#arr[@]} - 1; i >= 0; i-- )); do    # reverse iteration
    echo "${arr[i]}"
done

This is the form for counters and index arithmetic. Inside (( )) you write i, not $i — arithmetic context expands variables automatically.


Topic 2: while and until

while condition; do ... done      # while it SUCCEEDS (exit 0)
until condition; do ... done      # until it succeeds — the inverse

The condition is a command, not an expression. Its exit status is what the loop tests.

Reading input — the most common use:

while IFS= read -r line; do
    printf '%s\n' "$line"
done < input.txt

Covered fully in the file-reading lesson; the pieces are IFS= to keep whitespace, -r to keep backslashes, and < file to avoid a subshell.

Polling with a bound:

attempts=0
until curl -fsS --max-time 5 "http://localhost:8080/health" >/dev/null 2>&1; do
    (( attempts++ )) || true
    if (( attempts >= 30 )); then
        echo "service never became healthy" >&2
        exit 1
    fi
    sleep 2
done

Always bound a polling loop. An until with no attempt counter is a script that hangs forever when the thing never comes up — and under cron, that is a job that never finishes and blocks every subsequent run.

Infinite loops, deliberately:

while true; do ...; done
while :; do ...; done        # `:` is the null command — same effect

Topic 3: break and continue

for f in *.log; do
    [[ -s $f ]] || continue          # skip empty files
    if (( $(stat -c %s "$f") > 100000000 )); then
        echo "file too large, stopping: $f" >&2
        break
    fi
    process "$f"
done
  • continue skips to the next iteration.
  • break exits the loop entirely.

Both take a level for nested loops, which is the part people do not know:

for dir in */; do
    for f in "$dir"*.log; do
        [[ -r $f ]] || continue 2      # skip the whole DIRECTORY, not just this file
    done
done

break 2 and continue 2 operate on the enclosing loop. Far cleaner than a flag variable checked after the inner loop.

The guard-clause idiom:

for f in *.log; do
    [[ -r $f ]] || continue
    [[ -s $f ]] || continue
    grep -q ERROR "$f" || continue
    handle "$f"
done

Filtering with early continues keeps the body flat. The alternative — a nested if pyramid — is harder to read and easier to get wrong.


Topic 4: The Three Iteration Mistakes

1. Iterating command output with for:

for f in $(ls *.log); do ... done                 # WRONG
for f in $(find . -name '*.log'); do ... done     # WRONG

Both split on whitespace, so error log.txt becomes two iterations. Use a glob, or while read with a null delimiter:

for f in ./*.log; do ... done
while IFS= read -r -d '' f; do ... done < <(find . -name '*.log' -print0)

2. The pipeline subshell:

count=0
find . -name '*.log' | while read -r f; do (( count++ )); done
echo "$count"        # 0 — the loop ran in a subshell

Redirect or use process substitution instead:

while read -r f; do (( count++ )) || true; done < <(find . -name '*.log')

3. (( count++ )) under set -e:

set -e
count=0
(( count++ ))        # returns exit status 1 because the VALUE is 0 → script exits

(( )) returns non-zero when the expression evaluates to zero, and post-increment yields the old value. Use (( count++ )) || true, (( ++count )), or count=$(( count + 1 )).


Topic 5: Loops You Should Not Write

Shell loops fork a process per iteration for anything that is not a builtin. Over thousands of items that dominates the runtime.

# ~10,000 forks
for f in *; do
    basename "$f"
done

# zero forks
for f in *; do
    echo "${f##*/}"
done

Often the loop should not exist at all:

Instead of a loop over…Use
files, running one command eachfind ... -exec cmd {} +
files, in parallelfind ... -print0 | xargs -0 -P4 cmd
lines, extracting a fieldawk '{print $2}'
lines, counting occurrencessort | uniq -c
lines, substituting textsed 's/a/b/g'
# A loop calling gzip 5,000 times
for f in *.log; do gzip "$f"; done

# One process, batched arguments
find . -maxdepth 1 -name '*.log' -exec gzip {} +

The rule of thumb: if the loop body is a single external command, a tool probably already does it in one process. Reach for the loop when you need per-item logic, branching, or error handling.

Try it yourself: Create 2,000 files with touch file{1..2000}.txt, then time a loop calling basename against one using ${f##*/}. The difference is stark.

Common mistake: Writing for i in $(seq 1 $n) when for (( i=1; i<=n; i++ )) needs no subprocess and no quoting care. seq is fine for readability at small counts; the C-style loop is correct at any count.