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

List iteration
for item in list
Iterate over a fixed list of words, a glob, or the output of a command. Best choice when you know the items in advance.
Counted iteration
for (( i=0; i<N; i++ ))
C-style numeric loop. Best when you need an index counter or a precise range with a step.
Condition at top
while condition
Runs while the condition is true, checking before each iteration. May run zero times. Also the standard pattern for reading a file line by line.
Condition at top (inverted)
until condition
Runs until the condition becomes true — the opposite of while. Useful for "keep trying until it works" patterns.

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.

🐧 Iterating over lists
#!/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
When iterating over command output with $( ), 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

🐧 Processing all arguments passed to a script
#!/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
Always use "$@" (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.

🐧 C-style for loop
# 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
Brace expansion vs C-style for ranges: Use {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.

🐧 while loop patterns
#!/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, preventing read from 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 the done line, not on the while line.
🐧 Line-by-line file reading patterns
#!/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
When you pipe into a while loop (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.

🐧 until loop examples
# 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.

🐧 break and continue
# 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.

🐧 Redirecting and piping loop output
# 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.

🐧 Building a menu with select
#!/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.

🐧 Changing IFS to parse delimited 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"
⚠️ Always restore IFS. If you change IFS globally (not with the 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

SyntaxWhat it does
for x in list; do … doneIterate over a list of words
for x in "$@"; do … doneIterate over all script arguments
for x in *.txt; do … doneIterate over matching files (glob)
for x in {1..10}; do … doneIterate over a brace-expanded range
for x in {0..20..5}; do … doneRange with step
for (( i=0; i<N; i++ )); do … doneC-style counted loop
while condition; do … doneRun while condition is true
while true; do … doneInfinite loop (exit with break)
until condition; do … doneRun until condition becomes true
while IFS= read -r line; do … done < fileRead a file line by line
while IFS=',' read -r a b c; do … done < fileRead and split delimited file
breakExit the enclosing loop
break NExit N levels of nested loops
continueSkip to next iteration
done > fileRedirect entire loop output to file
done | cmdPipe entire loop output to a command
select x in list; do … doneDisplay numbered menu, prompt for choice
$REPLYRaw input from select prompt

✏️ Exercises

Apply what you have learned in this chapter. Try each exercise yourself before looking at the sample solution.

Exercise 1
Write a script called 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.
Hint: use 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.
Sample Solution
#!/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
Exercise 2
Write a script called 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.
Hint: use 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.
Sample Solution
#!/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"
Exercise 3
Write a script called 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.
Hint: use a C-style 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}.
Sample Solution
#!/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
Exercise 4
Write a script called 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.
Hint: use 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.
Sample Solution
#!/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."