for Loops
A for loop lets a Bash script repeat a block of commands once for each item in a list — a word, a filename, a number, anything you can enumerate. Instead of writing the same command three times for three servers, you write it once and let Bash iterate. for loops are the workhorse of shell scripting: renaming batches of files, looping over command-line arguments, retrying flaky network calls, and processing every file in a directory all rely on the same handful of patterns you’ll learn here.
Overview: How for Loops Work
A Bash for loop has two distinct forms that beginners often lump together but which behave very differently under the hood.
The list form — for var in list; do ... done — walks through a sequence of words one at a time and assigns each word to var in turn. The “list” is not a data structure Bash tracks internally; it is just whatever words remain after Bash finishes expanding everything on that line. This matters a lot: before the loop ever starts iterating, Bash performs word splitting and pathname (glob) expansion on the list exactly once, producing a flat list of words. If you write for file in *.log, the shell expands *.log into the matching filenames right there, before the loop body ever runs — the loop itself has no idea a glob was involved, it just sees a list of strings.
The C-style form — for (( init; condition; update )); do ... done — is a Bash extension borrowed from C. It maintains an integer counter, evaluates an arithmetic condition before each iteration, and runs an update expression after each iteration, exactly like a C for loop. This form does not touch word splitting or globbing at all; everything inside the double parentheses is arithmetic context.
Running the loop does not fork a new process for the loop construct itself — the loop executes in the current shell, so variable assignments made inside a for loop are still visible after it ends. The loop variable is an ordinary shell variable: it is not scoped to the loop, and it keeps its last-assigned value after the loop finishes.
A for loop’s own exit status is the exit status of the last command executed in its body (or 0 if the list was empty and the body never ran). That means checking $? right after a loop tells you how the last iteration’s last command went, not anything about the loop as a whole.
Syntax
for VARIABLE in WORD1 WORD2 WORD3; do
COMMANDS
done
and the C-style form:
for (( INIT; CONDITION; UPDATE )); do
COMMANDS
done
| Element | Meaning |
|---|---|
VARIABLE |
Name of the loop variable (no $ when declaring it); accessed inside the loop as $VARIABLE. |
in WORD1 WORD2 ... |
The list to iterate. Can be literal words, a glob pattern, $(command) output, brace expansion ({1..10}), or "$@" for script arguments. |
do ... done |
Marks the start and end of the loop body, required for both forms. |
(( INIT; CONDITION; UPDATE )) |
C-style arithmetic header: initialize a counter, test a condition each pass, update the counter after each pass. |
break |
Exits the loop immediately. |
continue |
Skips to the next iteration without running the rest of the body. |
Examples
Example 1: Looping Over a Fixed List
for env in dev staging production; do
echo "Deploying to $env..."
done
Output:
Deploying to dev...
Deploying to staging...
Deploying to production...
Bash assigns dev to env, runs the body, then repeats with staging, then production. Because there is no globbing or command substitution involved, this is a plain list of three words known before the loop even starts.
Example 2: Looping Over Files With a Glob
for file in /var/log/app/*.log; do
echo "Processing $file"
wc -l "$file"
done
Output:
Processing /var/log/app/access.log
842 /var/log/app/access.log
Processing /var/log/app/error.log
17 /var/log/app/error.log
Bash expands /var/log/app/*.log to the actual matching filenames before the loop starts, so file takes on each full path in turn. Note that "$file" is quoted inside the loop body — if a filename ever contained a space, the unquoted version would break into multiple arguments.
Example 3: C-Style Loop With a Retry and break
url="https://api.example.com/health"
for (( attempt=1; attempt<=3; attempt++ )); do
echo "Attempt $attempt: checking $url"
if curl --silent --fail "$url" > /dev/null; then
echo "Service is healthy"
break
fi
sleep 2
done
Output:
Attempt 1: checking https://api.example.com/health
Attempt 2: checking https://api.example.com/health
Attempt 3: checking https://api.example.com/health
This uses the C-style header to count from 1 to 3. On each pass it calls curl; if the service responds successfully, break exits the loop immediately instead of burning the remaining attempts. In the sample output shown, the service never responded successfully in time, so all three attempts ran and the loop ended naturally when attempt exceeded 3.
How It Works Step by Step
Take Example 2 apart:
- Bash reads the line
for file in /var/log/app/*.log; doand, before assigning anything, expands*.logagainst the filesystem. This is pathname expansion — the shell asks the kernel to list/var/log/app/and keeps only entries ending in.log. - The expansion produces a fixed list, e.g.
/var/log/app/access.log /var/log/app/error.log. If nothing matches and the shell optionnullglobis not set, the literal pattern*.logis used as the one and only “match” — a classic source of bugs, covered below. - Bash assigns the first word to
fileand enters the loop body, running each command in the current shell (no new process is forked for the loop construct itself, only for external commands likewc). - When it reaches
done, Bash checks whether more words remain in the list. If so, it assigns the next one tofileand runs the body again. - When the list is exhausted, control passes to whatever comes after
done. The variablefilestill holds its last value (/var/log/app/error.log) — it is not unset or reset.
Common Mistakes
Mistake 1: Looping Over Command Output Instead of a Glob
for file in $(ls *.txt); do
echo "Found: $file"
done
This looks reasonable but is wrong: $(ls *.txt) is command substitution, and its output is word-split on whitespace before the loop ever sees it. A file named meeting notes.txt becomes two separate words, meeting and notes.txt, silently corrupting the loop. Use the glob directly — it expands to full, correct filenames without any text parsing:
for file in *.txt; do
echo "Found: $file"
done
Mistake 2: Forgetting to Quote the Loop Variable
for file in "$HOME"/backups/*.tar.gz; do
rm $file
done
Without quotes, $file is subject to word splitting and glob expansion again when it’s used, not just when it’s assigned. A filename containing a space (or, worse, one containing a stray * left over from a failed expansion) can make rm receive arguments you never intended — potentially deleting the wrong file. Always quote variable expansions inside the loop body:
for file in "$HOME"/backups/*.tar.gz; do
rm "$file"
done
Best Practices
- Always quote variable expansions inside the loop body:
"$file", not$file. - Prefer a glob (
*.log) over parsinglsoutput —lsis meant for humans, and its output cannot be split reliably. - Use
"$@", not$@, when looping over script arguments, so arguments containing spaces stay intact:for arg in "$@"; do ... done. - Enable
shopt -s nullglobin scripts where a glob might match nothing, so an unmatched pattern expands to an empty list instead of the literal pattern string. - Use the C-style form for numeric counting and the list form for iterating real data — mixing them up (e.g.
for i in $(seq 1 100)) works but is less idiomatic thanfor (( i=0; i<100; i++ ))or brace expansion{1..100}. - Use
breakandcontinueto keep loop bodies flat instead of nesting deepifblocks. - Remember the loop variable survives after
done— do not rely on it being unset, and do not reuse the same variable name for an unrelated purpose right after a loop.
Practice Exercises
- Write a script that loops over every
.shfile in the current directory and prints its filename along with whether it is executable (hint: use[[ -x "$file" ]]inside the loop). - Write a script that takes any number of command-line arguments and prints each one prefixed with its position, e.g.
1: first-arg,2: second-arg. Use a counter variable alongside a loop over"$@". - Write a script that attempts to ping a host (
ping -c 1 "$host") up to five times, stopping early withbreakas soon as one attempt succeeds, and printing a final failure message if all five attempts fail.
Summary
for var in list; do ... doneiterates over a pre-expanded list of words;for (( init; cond; update )); do ... doneis a C-style numeric loop.- Globs and command substitutions in the list are expanded once, before the loop starts running.
- The loop variable is an ordinary shell variable — not scoped to the loop, and it keeps its last value afterward.
- Always quote
"$var"inside the loop body to avoid word splitting and unwanted glob expansion. - Prefer globs over parsing
lsoutput, and usebreak/continueto control iteration cleanly.
