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.-rkeeps backslashes literal.< hosts.txtfeeds the file in. Put it at the end of the loop, not ascat 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.