Bash For Loop Examples: Iterate Files, Arrays, and Counters Safely

bashfor-looparraysscripting
4 min read

A for loop that iterates over filenames without quoting is the single most common way a bash script passes every test and then corrupts data in production. A backup script we reviewed processed 847 files correctly in a developer's home directory, where every filename was a tidy report_2024.csv. On the production share it silently skipped 23 files — the ones a user had named with spaces, like Q3 forecast.xlsx — because the loop read an unquoted variable and word-split each of those names into fragments that did not exist. The script reported success. The backup was missing the 23 most important files. Nobody noticed for a week.

The fix is two habits, and once they are reflexes you never write the broken form again: loop over a glob, not over a parsed string, and quote every expansion.

The wrong pattern vs. the right pattern

Here is the broken form, copied almost verbatim from a highly-upvoted Stack Overflow answer:

bash
#!/bin/bash # Script: process-files-WRONG.sh # Purpose: Demonstrates the unquoted-loop bug that skips files with spaces. # Usage: ./process-files-WRONG.sh set -euo pipefail DIR="/data/reports" # WRONG: parsing ls and leaving $file unquoted. # A name with a space splits into two words; the loop touches paths that do not exist. for file in $(ls "$DIR"); do echo "Processing $file" done

And the form that survives real filenames:

bash
#!/bin/bash # Script: process-files-RIGHT.sh # Purpose: Iterate every file in a directory, including names with spaces. # Usage: ./process-files-RIGHT.sh set -euo pipefail CHECK="✓" DIR="/data/reports" # RIGHT: glob the directory so bash hands us each real path as one word, # and quote "$file" so a space inside the name never splits it. for file in "$DIR"/*; do [[ -e "$file" ]] || continue # the glob yields a literal '*' when the dir is empty echo "$CHECK Processing $file" done

Two differences carry the entire fix. "$DIR"/* lets bash expand the glob into a list where each filename is already one element — no string-building, no word-splitting. And "$file" keeps that element intact when you use it. The [[ -e "$file" ]] || continue guard handles the one edge case of globbing: when a directory is empty, * expands to the literal string *, so you skip it rather than trying to process a file named *.

Looping over arrays

When the list does not come from the filesystem, store it in an array and iterate the [@] form:

bash
servers=("web-01" "db-prod 02" "cache-03") for host in "${servers[@]}"; do echo "Connecting to $host" done

The distinction that bites people: "${servers[@]}" expands to three separate words — web-01, db-prod 02, cache-03 — even though the middle element contains a space, because each array element is kept whole. "${servers[*]}" would instead join all three into a single string (web-01 db-prod 02 cache-03) separated by the first character of IFS. If IFS has been changed earlier in the script — and in production scripts it often has, for parsing CSV or tab-delimited data — [*] produces output you did not expect while [@] keeps behaving correctly. Use [@] for iteration; reserve [*] for the rare case where you genuinely want one joined string.

C-style for loop with a counter

When you need an actual index rather than a list — processing app.log.1 through app.log.9, numbering batches, counting retries — use the C-style form:

bash
LOG_DIR="/var/log/app" MAX_ROTATED=9 # named, not a magic number: how many rotated logs to scan for ((i = 1; i <= MAX_ROTATED; i++)); do log="$LOG_DIR/app.log.$i" [[ -f "$log" ]] || continue echo "Scanning rotated log $i: $log" done

Do not try to write for i in {1..$MAX_ROTATED}. Brace expansion runs before variable expansion, so $MAX_ROTATED is never substituted and the loop iterates once over the literal text {1..$MAX_ROTATED}. The C-style (( )) form evaluates the variable correctly, which is exactly why it exists.

Loop with break and continue

continue skips the rest of the current iteration; break leaves the loop entirely. A real filtering example — process only .conf files, and stop after the first one that fails validation:

bash
for file in "$CONFIG_DIR"/*; do [[ "$file" == *.conf ]] || continue # skip anything that is not a config file if ! validate_config "$file"; then echo "$CROSS Stopping: $file failed validation" break # one bad config aborts the run fi echo "$CHECK $file is valid" done

break exits only the innermost loop. If this were nested inside another loop and you needed to escape both, break 2 does it. The same applies to continue 2, which jumps to the next iteration of the enclosing loop.

While loop vs. for loop — when to use each

Use a for loop when you have a finite, known list: files in a directory, elements of an array, a counter range. Use a while loop when you are consuming a stream whose length you do not know — most importantly, reading a file line by line:

bash
while IFS= read -r line; do echo "Line: $line" done < "$INPUT_FILE"

for line in $(cat "$INPUT_FILE") is always wrong, and it is wrong in two ways at once. It splits on every run of whitespace rather than on newlines, so a line containing spaces becomes several iterations and a blank line disappears. And it performs globbing, so a line that contains * expands to matching filenames in the current directory. The while IFS= read -r pattern fixes both: IFS= stops leading and trailing whitespace from being trimmed, and -r stops backslashes from being interpreted as escapes. Each iteration gets exactly one line, verbatim.

Complete production script

This puts the safe patterns together: glob the directory, quote every expansion, count successes and failures with $CHECK/$CROSS, and report a final tally instead of dying on the first problem.

bash
#!/bin/bash # Script: process-reports.sh # Purpose: Without safe quoting, files with spaces are skipped and the run reports false success. # Usage: ./process-reports.sh /path/to/reports set -euo pipefail CHECK="✓" CROSS="✗" REPORT_DIR="${1:?Usage: process-reports.sh <report-dir>}" # fail fast if no dir given processed=0 failed=0 # Glob the directory so each path arrives as one word, spaces and all. for report in "$REPORT_DIR"/*.csv; do # An empty match leaves the literal glob; skip it rather than process a file named '*.csv'. [[ -e "$report" ]] || { echo "$CROSS No CSV files found in $REPORT_DIR"; break; } # Quote "$report" everywhere it is used so a space in the name never splits it. if process_single "$report"; then echo "$CHECK Processed $report" ((processed++)) else echo "$CROSS Failed: $report" ((failed++)) fi done echo "$CHECK Done: $processed processed, $failed failed" [[ "$failed" -eq 0 ]] # non-zero exit if anything failed, so CI catches it

The loop never builds a string of filenames, never calls ls, and quotes the path on every use. It collects failures into a count and exits non-zero if any file failed, so a calling pipeline or CI step sees the real result instead of a green check over a silent miss. That is the difference between a loop that works on your laptop and one that works on the server.

BashSnippets logo

Written by Anguishe

Creator of BashSnippets.xyz

bashsnippets.xyz/about

Run this script on a real Linux server

Get $200 free credit — DigitalOcean

Get $200 Free →

Affiliate link · we earn a commission

Need a domain for your next project?

Register with Namecheap — free WHOIS privacy included

Check Domain Prices →

Affiliate link · we earn a commission

PAID RESOURCE — $9

The Production Bash Toolkit

6 scripts + shared library + 52-page field guide. The production layer the free snippets don't cover.

Get the Toolkit →

Related Snippets

Frequently Asked Questions

faq — snippet

Why does my bash for loop skip files with spaces in the name?

Because the variable is unquoted. for f in $files word-splits on every space, so "my report.txt" becomes two iterations: "my" and "report.txt", neither of which exists. Quote the expansion — "$f" — and glob the directory directly with for f in "$dir"/* instead of building a string of filenames.

faq — snippet

What is the difference between ${array[@]} and ${array[*]} in a for loop?

Inside double quotes, "${array[@]}" expands to one word per element, so a loop sees each element separately. "${array[*]}" joins all elements into a single string separated by the first character of IFS. For iteration you almost always want [@]; [*] is for building a single display string.

faq — snippet

Why is for line in $(cat file) wrong?

It word-splits the file on whitespace, not on newlines, so a line containing spaces becomes multiple iterations and empty lines vanish. It also globs, so a line containing * expands to filenames. Use while IFS= read -r line; do ...; done < file, which reads exactly one line per iteration.

faq — snippet

How do I loop a fixed number of times in bash?

Use a C-style loop: for ((i=1; i<=10; i++)); do ...; done. Avoid for i in {1..$n} because brace expansion happens before variable expansion, so the variable is not substituted and the loop runs once over the literal string {1..$n}.

faq — snippet

Does break exit all nested loops or just the inner one?

break exits only the innermost loop by default. Use break 2 to exit two levels at once. continue skips to the next iteration of the innermost loop, and continue 2 skips to the next iteration of the enclosing loop.