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 formfor 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 formfor (( 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:

  1. Bash reads the line for file in /var/log/app/*.log; do and, before assigning anything, expands *.log against the filesystem. This is pathname expansion — the shell asks the kernel to list /var/log/app/ and keeps only entries ending in .log.
  2. The expansion produces a fixed list, e.g. /var/log/app/access.log /var/log/app/error.log. If nothing matches and the shell option nullglob is not set, the literal pattern *.log is used as the one and only “match” — a classic source of bugs, covered below.
  3. Bash assigns the first word to file and 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 like wc).
  4. When it reaches done, Bash checks whether more words remain in the list. If so, it assigns the next one to file and runs the body again.
  5. When the list is exhausted, control passes to whatever comes after done. The variable file still 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 parsing ls output — ls is 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 nullglob in 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 than for (( i=0; i<100; i++ )) or brace expansion {1..100}.
  • Use break and continue to keep loop bodies flat instead of nesting deep if blocks.
  • 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

  1. Write a script that loops over every .sh file in the current directory and prints its filename along with whether it is executable (hint: use [[ -x "$file" ]] inside the loop).
  2. 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 "$@".
  3. Write a script that attempts to ping a host (ping -c 1 "$host") up to five times, stopping early with break as soon as one attempt succeeds, and printing a final failure message if all five attempts fail.

Summary

  • for var in list; do ... done iterates over a pre-expanded list of words; for (( init; cond; update )); do ... done is 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 ls output, and use break/continue to control iteration cleanly.