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:
And the form that survives real filenames:
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:
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:
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:
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:
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.
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.