| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user; no root required for the examples |
| Scope | Bash for loops: list form, script arguments (for arg; do), arrays, globs, brace ranges, C-style for (( )), nested loops, and a brief break/continue preview. Does not cover full break/continue behavior, line-by-line file reading, array CRUD, or glob option details. |
| Related guides | Bash shell script tutorial Shell scripting course hub |
Bash has two main for loop forms: a list loop that iterates over words, arguments, arrays, or filenames, and a C-style loop for arithmetic counters.
The right form depends on where the values come from. Unquoted command substitution in a list is risky when records may contain spaces, tabs, or newlines—the shell splits the result into separate words. The examples below use real paths and quoting so you can see the difference.
Bash for Loop Syntax
Bash gives you two loop shapes. The list form loops over a sequence of shell items (called words in Bash), such as strings, filenames, array elements, or script arguments. The C-style form runs an arithmetic counter.
for NAME [ [in WORDS ...] ] ; do COMMANDS ; done
for (( INITIALISATION ; CONDITION ; UPDATE )) ; do COMMANDS ; donedo starts the loop body and done ends it. The loop variable receives one item before each pass through that body. List form expands WORDS into separate shell words, assigns each one to NAME, and runs COMMANDS. C-style form evaluates the three arithmetic expressions like a C for loop; see Bash arithmetic when you need more than a simple counter.
The same loop can run on one command line when you separate the header, body, and terminator with semicolons:
for item in one two three; do echo "$item"; doneone
two
threeSemicolons let the loop fit on a single shell line. In scripts, the multiline form is usually easier to read.
A for loop returns the status of the last command executed in its body. If there are no items to iterate over, the loop returns 0.
Use this table to pick the form before you write the loop body:
| You have | Use | Example |
|---|---|---|
| Fixed literal items | List form | for env in prod staging dev; do |
| Script arguments | List form on "$@" or shorthand for arg; do |
for arg in "$@"; do |
| Array elements | List form on "${array[@]}" |
for item in "${items[@]}"; do |
| Filenames in one directory | List form with a glob pattern | for f in *.conf; do |
| Fixed numeric sequence | Brace expansion in list form | for i in {1..10}; do |
| Start/end from variables | C-style for (( )) |
for (( i=start; i<=end; i++ )); do |
Loop Over a List of Values
When the items are already on the command line as separate words, list form is the direct choice. Quote any element that contains spaces so the loop sees one word per item.
Loop over three deployment names, including one value that contains a space:
for env in production "staging west" development; do
echo "Deploy target: $env"
doneDeploy target: production
Deploy target: staging west
Deploy target: developmentEach echo line is one iteration. Without quotes around staging west, Bash would treat staging and west as two separate environments.
Loop Over Script Arguments
Scripts usually receive arguments in $1, $2, and so on. To visit every positional parameter, expand "$@" in the in clause. That keeps each argument as one word even when a path contains spaces.
To make this example runnable directly in your shell, set -- creates sample positional parameters. After this command, $1 is --verbose, $2 is my config.yaml, and so on:
set -- --verbose "my config.yaml" backup
for arg in "$@"; do
echo "Argument: $arg"
doneArgument: --verbose
Argument: my config.yaml
Argument: backupBash also accepts a shorthand when the loop variable should walk $@ and you omit the in list entirely:
for arg; do
echo "Shorthand: $arg"
doneShorthand: --verbose
Shorthand: my config.yaml
Shorthand: backupfor arg; do is equivalent to for arg in "$@"; do inside a function or script. It does nothing useful in an interactive shell with no positional parameters. See Bash script arguments for how $@ differs from $*.
Loop Over an Array Safely
When you want to loop over one array element per iteration, expand the array with "${array[@]}". That produces one word per array element, including elements that contain spaces.
environments=(production "staging west" development)
for env in "${environments[@]}"; do
echo "Deploy target: $env"
doneDeploy target: production
Deploy target: staging west
Deploy target: development"${array[*]}" is not the same input for a for loop. With the default IFS, it collapses all elements into a single word:
environments=(production "staging west" development)
for env in "${environments[*]}"; do
echo "Collapsed: $env"
doneCollapsed: production staging west developmentThe loop ran once because "${environments[*]}" became one long string. When you want one array element per iteration, use "${array[@]}". "${array[*]}" is not wrong—it intentionally joins elements into one string when quoted. Array creation and slicing live in Bash variables.
Loop Over Files with Globs
When filenames follow a pattern in the current directory, put the glob in the in list. Bash expands *.conf into separate words—one per matching file—before the loop starts.
Set up two config files, one with a space in the name:
mkdir -p confdir && cd confdir
printf 'port=80\n' > app.conf
printf 'port=443\n' > 'web server.conf'The glob runs after those files exist:
for file in *.conf; do
echo "Config: $file"
doneConfig: app.conf
Config: web server.confEach line is one file path, including web server.conf as a single iteration. That is why globs beat parsing ls output: ls prints columns, and spaces in filenames break naive for f in $(ls) loops.
Avoid unquoted command substitution when records may contain spaces, tabs, or newlines. Word splitting breaks paths:
for f in $(printf '%s\n' 'a file' 'b file'); do
echo "File: $f"
doneFile: a
File: file
File: b
File: fileFour iterations appeared instead of two because the shell split on whitespace. For line-oriented data, use a Bash while loop with while IFS= read -r line instead of list-form for.
When no file matches *.conf, Bash leaves the pattern unchanged and the loop runs once with the literal word *.conf. That surprise matters because an unmatched pattern can be processed as if it were a real filename.
Skip missing matches with a simple guard:
for file in *.conf; do
[[ -e "$file" ]] || continue
echo "$file"
done-e checks whether the path exists—enough to tell a real file from a literal *.conf string. Check if file exists in Bash covers -e, -f, and related tests inside loops. In Bash-only scripts, shopt -s nullglob makes an unmatched glob expand to nothing so the loop runs zero times.
Loop Over Numeric Ranges
Brace expansion {start..end} generates a fixed sequence before parameter expansion:
for i in {1..5}; do
echo "Count: $i"
doneCount: 1
Count: 2
Count: 3
Count: 4
Count: 5Add a third number to set the step:
for i in {0..20..5}; do
echo "Step: $i"
doneStep: 0
Step: 5
Step: 10
Step: 15
Step: 20Brace expansion is textual: it runs before parameter expansion. A variable upper bound does not work:
n=5
for i in {1..$n}; do
echo "Bad: $i"
doneBad: {1..5}Bash printed the literal text {1..5} because brace expansion happens before $n is expanded. Use a C-style loop when the bound comes from a variable.
Use a Variable Range with C-Style for (( ))
When start, end, or step come from variables, switch to arithmetic form. The expressions in the parentheses are evaluated as integers. For more counter patterns (while, list counts, file numbering), see counter in Bash.
start=3
end=7
for (( i=start; i<=end; i++ )); do
echo "Counter: $i"
doneCounter: 3
Counter: 4
Counter: 5
Counter: 6
Counter: 7Count down by changing the update expression:
for (( i=10; i>=1; i-- )); do
echo "Down: $i"
doneDown: 10
Down: 9
Down: 8
Down: 7
Down: 6
Down: 5
Down: 4
Down: 3
Down: 2
Down: 1Use i+=2 or i-=3 in the third clause when you need a step other than one. This section covers loop structure only; operator details sit in the arithmetic lesson linked above.
Nested for Loops
A loop inside another loop walks every combination of two lists. This pattern shows up when you check the same service on multiple hosts.
hosts=(web1 web2)
services=(nginx httpd)
for host in "${hosts[@]}"; do
for svc in "${services[@]}"; do
echo "Check $svc on $host"
done
doneCheck nginx on web1
Check httpd on web1
Check nginx on web2
Check httpd on web2Outer loop sets the host; inner loop walks services. break exits the innermost loop, while continue skips to its next iteration. In nested loops, break 2 exits two loop levels and continue 2 continues the next iteration of the second enclosing loop.
break and continue in a for Loop
continue skips the rest of the current iteration; break leaves the loop entirely.
for i in 1 2 3 4 5; do
if [[ $i -eq 3 ]]; then
continue
fi
if [[ $i -eq 5 ]]; then
break
fi
echo "Keep: $i"
doneKeep: 1
Keep: 2
Keep: 4Iteration 3 was skipped and the loop stopped before printing 5. For nested loops, numeric levels such as break 2 and continue 2 control how many enclosing loops are affected — Bash break and continue covers that in more depth.
Common for Loop Mistakes
| Symptom | Likely cause | Fix |
|---|---|---|
| One array element becomes many iterations | "${arr[*]}" instead of "${arr[@]}" |
Use "${arr[@]}" in the in list |
| Paths with spaces split apart | Unquoted $(command) in the in list |
Read lines with while IFS= read -r |
Wrong file count from ls |
Parsing ls columns |
Use for f in *.ext; do or find |
{1..$n} prints literally |
Brace expansion before $n |
for (( i=1; i<=n; i++ )) |
Loop runs once with *.missing |
No glob match | Test with [[ -e "$f" ]] or enable nullglob |
| Extra words from command output | Unquoted $(command) when records contain whitespace |
Loop over an array built with mapfile or use read |
Most of these are word-splitting problems disguised as loop bugs. Quote variables, match the loop form to the data source, and test with at least one filename that contains a space.
Practical for Loop Examples
Validate configuration files
Wrap the glob walk in a Bash function when you need a reusable validator. This one checks every regular .conf file in the current directory and reports per-file status:
validate_configs() {
local errors=0
local found=0
for file in *.conf; do
[[ -f "$file" ]] || continue
found=1
if grep -q '^port=' "$file"; then
echo "OK: $file"
else
echo "FAIL: $file missing port="
errors=$((errors + 1))
fi
done
if (( found == 0 )); then
echo "SKIP: no .conf files matched"
return 0
fi
echo "Validation errors: $errors"
(( errors == 0 ))
}With Bash's default glob behavior, an unmatched *.conf remains literal. The -f guard skips that literal pattern as well as non-regular matches, while found records whether at least one regular .conf file was processed.
Create two sample config files, including one filename with a space:
printf 'port=80\n' > app.conf && printf 'port=443\n' > 'web server.conf'Run the function against those files:
validate_configsOK: app.conf
OK: web server.conf
Validation errors: 0The glob delivered both files as separate iterations, so the space in web server.conf never split the name. When validation fails, (( errors == 0 )) makes the function return a non-zero status — Bash exit codes explains how callers read $? from that result.
Rename files by extension
A glob plus parameter expansion works for batch renames. Create two JPEG files, then loop over the pattern:
mkdir -p imgs && cd imgs
printf 'fake' > photo1.jpeg
printf 'fake' > photo2.jpegRename each match by stripping .jpeg and appending .jpg:
for f in *.jpeg; do
[[ -e "$f" ]] || continue
mv -- "$f" "${f%.jpeg}.jpg"
done${f%.jpeg} removes .jpeg from the end of the filename, so appending .jpg creates the new name. The % operator removes the shortest matching suffix.
List the results:
ls -1photo1.jpg
photo2.jpgSummary
Bash for loops repeat a block over a set of words. List form (for name in words; do) fits literal lists, "$@", "${array[@]}", and globs such as *.conf. C-style form (for (( ... )); do) fits numeric counters when start, end, or step live in variables.
The most common for loop mistakes come from how the input list is built, not from the loop syntax itself. "${array[@]}" preserves one element per iteration; "${array[*]}" does not. Globs hand you whole filenames; unquoted $(command) in a list is subject to word splitting, which breaks records containing spaces. Brace ranges like {1..5} are fixed before parameter expansion, so {1..$n} fails when n is a variable.
When the input is one record per line—log files, find output, or command transcripts—list-form for is the wrong tool. Reach for while IFS= read -r instead. For script arguments, for arg; do is a compact equivalent of for arg in "$@"; do worth keeping in your toolkit.
References
- GNU Bash Manual — Looping Constructs
- GNU Bash Manual — Brace Expansion
- GNU Bash Manual — Shell Expansions
- POSIX shell grammar — for loop
Frequently Asked Questions
How do I write a Bash for loop in one line?
Put the header, body, and done on one line with semicolons:
for i in {1..5}; do echo "$i"; doneSemicolons separate the loop header, body command, and done when written on one line.
How do I loop through filenames containing spaces?
Put a glob in the in list (for f in *.conf; do) and quote "$f" inside the body. Each glob match becomes one loop word. Avoid for f in $(ls) and for f in $(find ...) for filename iteration because command substitution is subject to word splitting. Use globs for simple directory patterns, or a find/read approach when you need recursive traversal.
What is the difference between for item in "$@" and for item; do?
Inside a script or function, for item; do is shorthand for for item in "$@"; do. Both visit positional parameters one at a time with correct quoting. The long form is clearer to readers who do not know the shorthand.
Should I use for or while read for a file?
Use for when you already have a list of separate words—arguments, array elements, or glob matches. Use while IFS= read -r line when the file has one logical record per line and you must not split on spaces inside a field.

