Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Looping in Bash: for, while, until, and the Loop That Eats Your Filenames

Sean

Platform Writer

Aug 13, 2026
8 min read

Bash gives you for item in list, a C-style for ((i=0; i<n; i++)), while condition, and until condition. The one thing that separates a loop that works from one that breaks on real data is how it handles whitespace: for f in $(ls) splits filenames on spaces and will eventually do something you did not intend. Use globs or while read -r.

Looping in Bash: for, while, until, and the Loop That Eats Your Filenames

Loop syntax in bash is short and mostly memorable. The failure modes are all about quoting and word splitting, so those get more space here than the syntax does.

Table of contents

The four loop forms

# for-in over a list
for svc in api worker scheduler; do
    echo "restarting $svc"
done

# C-style, when you need an index
for (( i = 0; i < 5; i++ )); do
    echo "attempt $i"
done

# while -- run as long as the condition succeeds
count=0
while (( count < 3 )); do
    echo "count is $count"
    (( count++ ))
done

# until -- run until the condition succeeds
until curl -sf http://localhost:8080/health >/dev/null; do
    echo "waiting for the service"
    sleep 1
done

until is while with the condition inverted. It reads better for waiting on something to become true, which is its main use — until service_is_up says what it means more clearly than while ! service_is_up.

Ranges and sequences

# Brace expansion -- literal bounds only
for i in {1..10}; do echo "$i"; done
for i in {0..20..5}; do echo "$i"; done      # step of 5
for c in {a..e}; do echo "$c"; done          # a b c d e

# Brace expansion does NOT expand variables
n=10
for i in {1..$n}; do echo "$i"; done         # prints the literal {1..10}

# Use a C-style loop with variables
for (( i = 1; i <= n; i++ )); do echo "$i"; done

# Or seq
for i in $(seq 1 "$n"); do echo "$i"; done

The {1..$n} trap is worth internalising. Brace expansion happens before variable expansion, so the variable is still a literal $n when the braces are processed. The C-style loop is the right answer whenever a bound is dynamic.

Looping over files without breaking on spaces

This is the section that matters. The naive form is wrong and works often enough that people keep writing it.

# Wrong -- splits on whitespace, so "my report.txt" becomes two items
for f in $(ls *.txt); do
    echo "processing $f"
done

# Right -- glob directly, no command substitution
for f in *.txt; do
    [ -e "$f" ] || continue      # handles the no-matches case
    echo "processing $f"
done

A glob produces a proper list of words, one per file, regardless of what characters are in the names. Command substitution produces a single string that the shell then splits on IFS, which is where the damage happens.

The [ -e "$f" ] || continue line handles the case where nothing matches — bash leaves the pattern unexpanded, so the loop runs once with f set to the literal *.txt. shopt -s nullglob makes an unmatched glob expand to nothing instead, which is cleaner if you control the script’s options.

Reading a file line by line

# The correct incantation
while IFS= read -r line; do
    echo "line: $line"
done < input.txt

Three deliberate pieces, each fixing a specific failure:

  • IFS= — prevents leading and trailing whitespace being stripped from each line.
  • -r — stops read interpreting backslashes as escapes, so a Windows path in the file survives intact.
  • < input.txt on the done — redirects the file into the loop, rather than cat file | while, which puts the loop in a subshell where variable changes do not survive.

That last one causes a genuinely baffling bug:

# count is 0 afterwards -- the loop ran in a subshell
count=0
cat input.txt | while read -r line; do (( count++ )); done
echo "$count"     # 0

# count is correct -- no subshell
count=0
while read -r line; do (( count++ )); done < input.txt
echo "$count"     # the real number

One more subtlety: if the loop body runs a command that reads stdin — ssh, ffmpeg, sometimes docker — it consumes the rest of your input file and the loop ends after one iteration. Fix it with ssh -n, or redirect that command’s stdin from /dev/null.

Recursive and null-delimited iteration

For files nested at arbitrary depth, find with -print0 is the safe pairing, because null is the one byte a filename cannot contain.

# Handles newlines, spaces, quotes -- anything
while IFS= read -r -d '' file; do
    echo "found: $file"
done < <(find . -name '*.log' -print0)

# Or with globstar, for the simple recursive case
shopt -s globstar nullglob
for f in **/*.log; do
    echo "found: $f"
done

The < <(...) is process substitution — it feeds the command’s output in as a file, keeping the loop out of a subshell. globstar is simpler when you have it and do not need find’s filtering.

break, continue, and loops over arrays

services=(api worker scheduler)

for svc in "${services[@]}"; do
    [[ $svc == worker ]] && continue      # skip this one
    [[ $svc == bad ]] && break            # stop entirely
    echo "deploying $svc"
done

# With the index
for i in "${!services[@]}"; do
    echo "$i: ${services[$i]}"
done

# break out of two nested loops
for a in 1 2 3; do
    for b in x y z; do
        [[ $b == y ]] && break 2
    done
done

"${services[@]}" with the quotes and [@] is the only correct way to iterate an array. ${services[*]} joins into one string, and unquoted ${services[@]} re-splits elements containing spaces.

Parallelism, and when the loop is the wrong shape

A sequential loop over 500 network calls takes as long as 500 network calls. Backgrounding inside a loop is the easy fix and the easy way to fork-bomb yourself.

# Bounded parallelism -- four at a time
max=4
for url in "${urls[@]}"; do
    while (( $(jobs -rp | wc -l) >= max )); do wait -n; done
    fetch "$url" &
done
wait

# Or hand it to xargs, which does the bookkeeping for you
printf '%s\n' "${urls[@]}" | xargs -P 4 -I{} curl -sS -o /dev/null {}

xargs -P is usually the better tool. It handles the slot management, and the whole thing is one line instead of a nested loop with a job counter.

The wider signal: when a loop grows retries, parallelism, and error collection, it has become a job runner living in a shell script. That is fine for a build step and poor as a production component — there is no history of previous runs and no logs except what you remembered to redirect. Work that has to be observable belongs in a service with runtime logs and deploy history rather than a script somebody runs by hand.

How this fits the rest of the stack

for x in *.glob for files, while IFS= read -r line; do ... done < file for lines, C-style for ((...)) when a bound is a variable, and until when you are waiting for something to come up. Never for f in $(ls). Redirect into done rather than piping into while, or your variables vanish into a subshell.

When the loop turns into a scheduler with parallelism and retries, that is the point to move it into something with logs you did not have to build. If you are sizing up what a worker service and its database cost, the RunxBuild hosting calculator lists them as separate line items rather than a single figure.

Useful related references:

FAQ

How do I write a for loop in bash?

for item in list; do ... done iterates a list, and for (( i = 0; i < n; i++ )); do ... done gives a C-style counter. Use the C-style form whenever a bound comes from a variable, because brace expansion like {1..$n} does not expand variables and produces the literal text instead.

Why does my bash loop break on filenames with spaces?

Because for f in $(ls) produces one string that the shell splits on whitespace. Iterate a glob instead — for f in *.txt — which yields one word per file no matter what characters the names contain. For recursive cases, pair find -print0 with while IFS= read -r -d ''.

How do I read a file line by line in bash?

while IFS= read -r line; do ... done < file.txt. IFS= preserves leading and trailing whitespace, -r stops backslash interpretation, and redirecting on done avoids the subshell you get from cat file | while read, where variable changes are lost when the loop ends.

What is the difference between while and until in bash?

They are inverses. while runs the body as long as the condition succeeds; until runs it until the condition succeeds. until reads more naturally for waiting on something to become available, such as polling a health endpoint before continuing.

How do I run loop iterations in parallel in bash?

Background each iteration with & and cap concurrency using wait -n when the job count reaches your limit, then wait at the end. Often simpler is piping the list to xargs -P N, which manages the parallel slots itself and keeps the script to one line.

#bash loop#for loop#while loop#shell scripting#linux