Bash for Loop: Syntax and Examples

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
Short on time?Beta Get the gist of this 12-minute read in seconds, then ask follow-up questions

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.

text
for NAME [ [in WORDS ...] ] ; do COMMANDS ; done
for (( INITIALISATION ; CONDITION ; UPDATE )) ; do COMMANDS ; done

do 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:

bash
for item in one two three; do echo "$item"; done
output
one
two
three

Semicolons 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:

bash
for env in production "staging west" development; do
  echo "Deploy target: $env"
done
output
Deploy target: production
Deploy target: staging west
Deploy target: development

Each 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:

bash
set -- --verbose "my config.yaml" backup
for arg in "$@"; do
  echo "Argument: $arg"
done
output
Argument: --verbose
Argument: my config.yaml
Argument: backup

Bash also accepts a shorthand when the loop variable should walk $@ and you omit the in list entirely:

bash
for arg; do
  echo "Shorthand: $arg"
done
output
Shorthand: --verbose
Shorthand: my config.yaml
Shorthand: backup

for 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.

bash
environments=(production "staging west" development)
for env in "${environments[@]}"; do
  echo "Deploy target: $env"
done
output
Deploy 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:

bash
environments=(production "staging west" development)
for env in "${environments[*]}"; do
  echo "Collapsed: $env"
done
output
Collapsed: production staging west development

The 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:

bash
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:

bash
for file in *.conf; do
  echo "Config: $file"
done
output
Config: app.conf
Config: web server.conf

Each 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:

bash
for f in $(printf '%s\n' 'a file' 'b file'); do
  echo "File: $f"
done
output
File: a
File: file
File: b
File: file

Four 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:

bash
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:

bash
for i in {1..5}; do
  echo "Count: $i"
done
output
Count: 1
Count: 2
Count: 3
Count: 4
Count: 5

Add a third number to set the step:

bash
for i in {0..20..5}; do
  echo "Step: $i"
done
output
Step: 0
Step: 5
Step: 10
Step: 15
Step: 20

Brace expansion is textual: it runs before parameter expansion. A variable upper bound does not work:

bash
n=5
for i in {1..$n}; do
  echo "Bad: $i"
done
output
Bad: {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.

bash
start=3
end=7
for (( i=start; i<=end; i++ )); do
  echo "Counter: $i"
done
output
Counter: 3
Counter: 4
Counter: 5
Counter: 6
Counter: 7

Count down by changing the update expression:

bash
for (( i=10; i>=1; i-- )); do
  echo "Down: $i"
done
output
Down: 10
Down: 9
Down: 8
Down: 7
Down: 6
Down: 5
Down: 4
Down: 3
Down: 2
Down: 1

Use 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.

bash
hosts=(web1 web2)
services=(nginx httpd)
for host in "${hosts[@]}"; do
  for svc in "${services[@]}"; do
    echo "Check $svc on $host"
  done
done
output
Check nginx on web1
Check httpd on web1
Check nginx on web2
Check httpd on web2

Outer 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.

bash
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"
done
output
Keep: 1
Keep: 2
Keep: 4

Iteration 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:

bash
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:

bash
printf 'port=80\n' > app.conf && printf 'port=443\n' > 'web server.conf'

Run the function against those files:

bash
validate_configs
output
OK: app.conf
OK: web server.conf
Validation errors: 0

The 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:

bash
mkdir -p imgs && cd imgs
printf 'fake' > photo1.jpeg
printf 'fake' > photo2.jpeg

Rename each match by stripping .jpeg and appending .jpg:

bash
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:

bash
ls -1
output
photo1.jpg
photo2.jpg

Summary

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


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:

bash
for i in {1..5}; do echo "$i"; done

Semicolons 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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)