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.
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
- Ranges and sequences
- Looping over files without breaking on spaces
- Reading a file line by line
- Recursive and null-delimited iteration
- break, continue, and loops over arrays
- Parallelism, and when the loop is the wrong shape
- How this fits the rest of the stack
- FAQ
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— stopsreadinterpreting backslashes as escapes, so a Windows path in the file survives intact.< input.txton thedone— redirects the file into the loop, rather thancat 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:
- Bash Array: Declaring, Looping, and the Quoting That Breaks Everything
- Bash Append to File:
>>,tee -a, and Heredoc - How to Run .sh File in Linux: bash, chmod, and shebang
- Services on RunxBuild
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.