Loops
🔁 Topic 6 — Loops
Loops let a script repeat a block of commands — iterating over a list, counting through a range, reading every line of a file, or running until a condition changes. Bash provides four loop constructs, each suited to a different situation. This chapter covers all four, along with break, continue, the IFS-aware file-reading pattern, loop output redirection, and the interactive select menu loop.
1 — Which Loop to Use
2 — for Loop: List Style
The list-style for loop assigns each word in a list to a variable in turn and runs the loop body for each one. The list can be a literal sequence, a brace expansion, a glob pattern, or the output of a command.
#!/bin/bash
# Literal list
for fruit in apple banana cherry mango; do
echo "Fruit: $fruit"
done
Fruit: apple
Fruit: banana
Fruit: cherry
Fruit: mango
# Brace expansion — {start..end} or {start..end..step}
for i in {1..5}; do
echo -n "$i "
done
echo
1 2 3 4 5
for i in {0..20..5}; do # step of 5
echo -n "$i "
done
echo
0 5 10 15 20
# Glob — iterate over matching files
for file in /etc/*.conf; do
echo "Config: $file"
done
# Command substitution — iterate over output lines
for user in $(cut -d: -f1 /etc/passwd | head -5); do
echo "User: $user"
done
$( ), word splitting applies — lines with spaces are split into multiple items. Use a while read loop (section 5) when lines may contain spaces.Looping Over Script Arguments
#!/bin/bash
# Process every argument passed to this script
for arg in "$@"; do
echo "Processing: $arg"
done
# Shorthand — omitting 'in "$@"' is equivalent
for arg; do
echo "Processing: $arg"
done
"$@" (not $*) to preserve arguments that contain spaces — each argument stays as a single item regardless of internal whitespace.3 — for Loop: C Style
The C-style for loop uses arithmetic expressions for initialisation, condition, and update. It is the best choice when you need a numeric index or a loop that counts with a custom step.
# Basic: count from 1 to 10
for (( i=1; i<=10; i++ )); do
echo -n "$i "
done
echo
1 2 3 4 5 6 7 8 9 10
# Count down
for (( i=5; i>0; i-- )); do
echo -n "$i "
done
echo
5 4 3 2 1
# Step by 2
for (( i=0; i<=10; i+=2 )); do
echo -n "$i "
done
echo
0 2 4 6 8 10
# Using the index to access a positional parameter
for (( i=1; i<=$#; i++ )); do
echo "Arg $i: ${!i}" # ${!i} = indirect expansion — value of the i-th arg
done
{1..10} when the range limits are fixed literals. Use (( i=1; i<=n; i++ )) when the end value is a variable — brace expansion does not expand variables: {1..$n} does not work.
4 — while Loop
A while loop evaluates its condition before each iteration and runs the body as long as the condition is true (exit status 0). It is the right choice when you don't know how many iterations are needed in advance.
#!/bin/bash
# Count with a while loop
count=1
while [[ $count -le 5 ]]; do
echo "Count: $count"
(( count++ ))
done
Count: 1 … Count: 5
# Infinite loop — runs until broken from inside
while true; do
read -r -p "Enter 'quit' to exit: " input
if [[ "$input" == "quit" ]]; then
echo "Goodbye!"
break
fi
echo "You typed: $input"
done
# Retry with a limit — keep trying until success or max attempts
attempts=0
max=3
while (( attempts < max )); do
read -r -s -p "Password: " pw; echo
if [[ "$pw" == "secret" ]]; then
echo "Access granted."; break
fi
(( attempts++ ))
echo "Wrong. $((max - attempts)) attempt(s) remaining."
done
[[ $attempts -ge $max ]] && echo "Locked out."
5 — Reading a File Line by Line
The most important and most common use of while in real scripts is reading a file line by line. The canonical pattern is:
while IFS= read -r line; do
# process $line
done < "$filename"
Each part of that one-liner matters:
IFS=— sets the Internal Field Separator to empty for this one command, preventingreadfrom stripping leading and trailing whitespace from each line.read -r— raw mode: backslashes are treated literally, not as escape characters.< "$filename"— redirects the file into the loop's stdin. The redirection goes on thedoneline, not on thewhileline.
#!/bin/bash
# Basic: print each line with a line number
linenum=0
while IFS= read -r line; do
(( linenum++ ))
printf "%4d %s\n" "$linenum" "$line"
done < /etc/hosts
# Skip blank lines and comment lines
while IFS= read -r line; do
[[ -z "$line" ]] && continue # skip blank
[[ "$line" == \#* ]] && continue # skip comments
echo "Active entry: $line"
done < /etc/hosts
# Split each line into fields using IFS
# /etc/passwd is colon-delimited: user:x:uid:gid:comment:home:shell
while IFS=':' read -r user _pw uid gid comment home shell; do
printf "%-15s uid=%-6s %s\n" "$user" "$uid" "$shell"
done < /etc/passwd
# Read from a pipe (note: variables set inside may not persist)
ps aux | while IFS= read -r line; do
[[ "$line" == *"bash"* ]] && echo "$line"
done
cmd | while … done), the loop body runs in a subshell on most systems — variables set inside will not be visible after the loop. Use while … done < <(cmd) (process substitution) to avoid this.6 — until Loop
until is the logical inverse of while: it runs the loop body as long as the condition is false, stopping when the condition becomes true. Every until loop can be rewritten as a while with a negated condition — use whichever reads more naturally.
# Wait until a file appears
echo "Waiting for /tmp/ready.flag ..."
until [[ -f "/tmp/ready.flag" ]]; do
sleep 1
done
echo "Flag found — proceeding."
# Equivalent with while (negated condition):
while [[ ! -f "/tmp/ready.flag" ]]; do
sleep 1
done
# Count up with until
n=1
until (( n > 5 )); do
echo "n = $n"
(( n++ ))
done
7 — break and continue
break exits the enclosing loop immediately. continue skips the rest of the current iteration and moves to the next one. Both accept an optional numeric argument to target an outer loop when loops are nested.
# continue — skip even numbers, print only odds 1–10
for (( i=1; i<=10; i++ )); do
(( i % 2 == 0 )) && continue
echo -n "$i "
done
echo
1 3 5 7 9
# break — stop at the first file larger than 1 MB
for file in /var/log/*.log; do
size=$(stat -c%s "$file")
if (( size > 1048576 )); then
echo "Large file found: $file ($size bytes)"
break
fi
done
# Nested loops — break 2 exits both the inner AND outer loop
for i in {1..3}; do
for j in {1..3}; do
if (( i == 2 && j == 2 )); then
echo "Breaking out of both loops at i=$i j=$j"
break 2
fi
echo "i=$i j=$j"
done
done
i=1 j=1
i=1 j=2
i=1 j=3
i=2 j=1
Breaking out of both loops at i=2 j=2
8 — Redirecting Loop Output
You can redirect the entire output of a loop — or pipe it — by placing the redirection operator after done. This is far cleaner than redirecting inside every echo call.
# Write the entire loop's output to a file
for i in {1..5}; do
echo "Line $i"
done > output.txt
# Append loop output to a log file
for file in *.sh; do
echo "$(date): processing $file"
done >> process.log
# Pipe a loop's output to another command
for name in charlie alice bob diana; do
echo "$name"
done | sort
alice
bob
charlie
diana
# Capture loop output into a variable
result=$(
for i in {1..3}; do
echo "item$i"
done
)
echo "$result"
9 — select: Interactive Menus
select is a special loop that displays a numbered menu from a list and prompts the user to choose an option. It is the standard way to build an interactive menu in a bash script.
#!/bin/bash
# PS3 is the prompt shown to the user (default is "#? ")
PS3="Choose an action: "
select choice in "Show disk usage" "Show uptime" "List users" "Quit"; do
case "$choice" in
"Show disk usage") df -h / ;;
"Show uptime") uptime ;;
"List users") cut -d: -f1 /etc/passwd ;;
"Quit") echo "Bye!"; break ;;
*) echo "Invalid choice: $REPLY" ;;
esac
done
$REPLY holds the raw text the user typed. $choice holds the corresponding menu item string (or empty if the number was out of range). The menu is redisplayed after each selection unless you break.10 — IFS and Word Splitting in Loops
The Internal Field Separator (IFS) controls how bash splits words. Its default value is space, tab, and newline. Changing IFS in a loop lets you split on any delimiter — very useful for processing CSV or colon-separated data.
# Split a CSV string into fields
record="Philip,32,London,Engineer"
IFS=',' read -r -a fields <<< "$record"
echo "Name : ${fields[0]}"
echo "Age : ${fields[1]}"
echo "City : ${fields[2]}"
# Loop through a CSV file, one record per line
while IFS=',' read -r name age city; do
printf "%-15s %-5s %s\n" "$name" "$age" "$city"
done < people.csv
# Temporarily change IFS for a for loop — restore afterwards
old_IFS="$IFS"
IFS=':'
for dir in $PATH; do # $PATH split on : without quotes
echo "$dir"
done
IFS="$old_IFS"
IFS= read single-command form), save the original first and restore it afterwards. A changed IFS will silently break other parts of your script that rely on word splitting.
11 — Quick Reference
| Syntax | What it does |
|---|---|
for x in list; do … done | Iterate over a list of words |
for x in "$@"; do … done | Iterate over all script arguments |
for x in *.txt; do … done | Iterate over matching files (glob) |
for x in {1..10}; do … done | Iterate over a brace-expanded range |
for x in {0..20..5}; do … done | Range with step |
for (( i=0; i<N; i++ )); do … done | C-style counted loop |
while condition; do … done | Run while condition is true |
while true; do … done | Infinite loop (exit with break) |
until condition; do … done | Run until condition becomes true |
while IFS= read -r line; do … done < file | Read a file line by line |
while IFS=',' read -r a b c; do … done < file | Read and split delimited file |
break | Exit the enclosing loop |
break N | Exit N levels of nested loops |
continue | Skip to next iteration |
done > file | Redirect entire loop output to file |
done | cmd | Pipe entire loop output to a command |
select x in list; do … done | Display numbered menu, prompt for choice |
$REPLY | Raw input from select prompt |
✏️ Exercises
Apply what you have learned in this chapter. Try each exercise yourself before looking at the sample solution.
rename_ext.sh that accepts two arguments — an old extension and a new extension (e.g. ./rename_ext.sh txt md) — and renames all files in the current directory with the old extension to use the new one. Print a line for each file renamed, or a message if no matching files are found. Run it in a test directory with dummy files.for file in *."$1" to match files with the given extension. Use ${file%.*} to strip the extension, then append the new one. Use mv "$file" "$newname" to rename.#!/bin/bash
# rename_ext.sh — usage: ./rename_ext.sh old_ext new_ext
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <old_ext> <new_ext>" >&2; exit 1
fi
old="$1"
new="$2"
count=0
for file in *."$old"; do
[[ -f "$file" ]] || continue # skip if glob didn't match anything
newname="${file%.*}.$new"
mv "$file" "$newname"
echo "Renamed: $file → $newname"
(( count++ ))
done
if (( count == 0 )); then
echo "No .$old files found."
else
echo "$count file(s) renamed."
fi
csv_report.sh that reads a CSV file (create a sample one first with columns name,score,grade) line by line, skips the header row, and prints a formatted table. Count how many students passed (grade A or B) and print the total at the end.while IFS=',' read -r name score grade with input redirected from the CSV. Use a counter variable and a case or [[ ]] test on $grade to count passes. Skip the header by using a flag variable or read once before the loop.#!/bin/bash
# csv_report.sh
# Sample CSV (students.csv):
# name,score,grade
# Alice,92,A
# Bob,74,B
# Carol,58,C
# Dave,88,A
# Eve,41,F
file="students.csv"
[[ -f "$file" ]] || { echo "File not found: $file" >&2; exit 1; }
passes=0
header=true
printf "%-15s %6s %6s\n" "Name" "Score" "Grade"
printf '%.0s─' {1..30}; echo
while IFS=',' read -r name score grade; do
if $header; then header=false; continue; fi # skip header row
printf "%-15s %6s %6s\n" "$name" "$score" "$grade"
[[ "$grade" == "A" || "$grade" == "B" ]] && (( passes++ ))
done < "$file"
printf '%.0s─' {1..30}; echo
echo "Students with A or B: $passes"
times_table.sh that accepts a number as an argument and prints its times table from 1 to 12. Then extend it: if no argument is given, use a select menu to let the user choose a number from 2 to 12, then print that table.for (( i=1; i<=12; i++ )) loop with printf for alignment. For the select menu, build the list with brace expansion: select n in {2..12}.#!/bin/bash
# times_table.sh
print_table() {
n="$1"
echo "── $n times table ──"
for (( i=1; i<=12; i++ )); do
printf "%2d × %2d = %3d\n" "$n" "$i" "$(( n * i ))"
done
}
if [[ -n "$1" ]]; then
print_table "$1"
else
PS3="Choose a number (or Ctrl+C to quit): "
select num in {2..12}; do
if [[ -n "$num" ]]; then
print_table "$num"
break
else
echo "Invalid choice."
fi
done
fi
disk_watch.sh that uses a while loop to check disk usage on / every 3 seconds. Each iteration it should print the current usage percentage and the time. If usage exceeds 80%, print a warning and exit. After 5 checks with no alert, print "All clear" and exit normally.df / | tail -1 | awk '{print $5}' to get the usage percentage (it returns something like 42%). Strip the % with ${pct%\%} before comparing numerically. Use a counter to track iterations.#!/bin/bash
# disk_watch.sh
checks=0
max_checks=5
threshold=80
while (( checks < max_checks )); do
pct_raw=$(df / | tail -1 | awk '{print $5}')
pct="${pct_raw%\%}" # strip the % sign
timestamp=$(date +"%H:%M:%S")
printf "[%s] Disk usage: %s%%\n" "$timestamp" "$pct"
if (( pct > threshold )); then
echo "WARNING: disk usage above ${threshold}%! Taking action." >&2
exit 1
fi
(( checks++ ))
(( checks < max_checks )) && sleep 3
done
echo "All clear after $max_checks checks."