Loops are where shell scripting starts paying off: do the same thing to 50 files, 10 servers or every line of a list. This guide covers Bash for loops, their close cousin while read, and the mistakes that cause most loop bugs.

Basic syntax

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

On one line:

for s in web1 web2 web3; do ssh "$s" uptime; done

Loop over files with a glob

for f in /var/log/nginx/*.log; do
  echo "$f: $(wc -l < "$f") lines"
done

Globs are the right way to loop over files. They handle spaces in names correctly, as long as you quote "$f" inside the loop.

If nothing matches, the loop runs once with the literal pattern (/var/log/nginx/*.log). Guard against that:

shopt -s nullglob          # no match → the loop doesn't run
for f in /backups/*.sql.gz; do …; done

Or check inside the loop: [ -e "$f" ] || continue.

Recursive matching (Bash 4+):

shopt -s globstar
for f in /var/www/**/*.php; do …; done

Ranges

for i in {1..5}; do echo "$i"; done          # 1 2 3 4 5
for i in {0..100..10}; do echo "$i"; done    # step of 10
for i in $(seq 1 "$n"); do …; done           # when the end is a variable

Brace ranges can’t use variables ({1..$n} doesn’t expand). Use seq or a C-style loop.

C-style loop

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

Loop over an array

servers=(web1 web2 "db primary")
for s in "${servers[@]}"; do
  echo "$s"
done

"${array[@]}" with quotes keeps elements that contain spaces intact. See Bash variables and arrays.

Loop over lines: use while read, not for

This is the classic mistake:

for line in $(cat hosts.txt); do …; done     # splits on spaces too, and expands globs

The safe way to read a file line by line:

while IFS= read -r line; do
  echo "Host: $line"
done < hosts.txt
  • IFS= keeps leading and trailing whitespace.
  • -r keeps backslashes literal.
  • < hosts.txt feeds the file in. Put it at the end of the loop, not as cat file | while. A pipe runs the loop in a subshell, so variables set inside it are lost afterwards.

Split columns while reading:

while IFS=: read -r user _ uid _ _ home shell; do
  [ "$uid" -ge 1000 ] && echo "$user $home $shell"
done < /etc/passwd

Command output, with NUL separators for safety:

while IFS= read -r -d '' f; do
  gzip "$f"
done < <(find /var/log/app -name '*.log' -mtime +7 -print0)

SSH inside while read

ssh reads stdin and swallows the rest of your list, so the loop runs only once. Use ssh -n or redirect stdin:

while read -r host; do
  ssh -n "$host" 'df -h /'
done < hosts.txt

break and continue

for f in *.csv; do
  [ -s "$f" ] || continue        # skip empty files
  if grep -q 'FATAL' "$f"; then
    echo "found in $f"
    break                        # stop the loop
  fi
done

Practical examples

Back up several databases:

for db in shop blog crm; do
  mysqldump --single-transaction "$db" | gzip > "/var/backups/${db}-$(date +%F).sql.gz"
done

Retry until success:

for i in {1..5}; do
  curl -fsS http://127.0.0.1:3000/health && break
  echo "not ready, retry $i"; sleep 3
done

Rename files:

for f in *.jpeg; do mv -n -- "$f" "${f%.jpeg}.jpg"; done

Check certificates on several domains:

for d in example.com shop.example.com; do
  printf '%-25s ' "$d"
  echo | openssl s_client -servername "$d" -connect "$d:443" 2>/dev/null | openssl x509 -noout -enddate
done

Common mistakes

Mistake Fix
for f in $(ls) for f in *
for line in $(cat file) while IFS= read -r line; do …; done < file
Unquoted $f "$f"
{1..$n} seq 1 "$n" or for ((…))
Variables lost after cmd | while while …; done < <(cmd)
ssh eating the loop’s input ssh -n

Related: Bash if/else, Bash scripting tutorial and xargs.

  • Bash
  • Scripting
  • Linux