Bash for Loop in Shell Scripts: Syntax and Split Filenames

LinuxForDevices featured banner: a word list splitting into three loop iterations, with one word arriving in two pieces

A for loop that renames files can act on half a filename, because bash splits the word list on whitespace before the body runs. A file holding the names report final.txt and notes.txt reaches the loop as three words, so the command in the body runs against a path that does not exist.

Quoting inside the body cannot put a name back together once expansion has split it.

What bash builds before the loop body runs

The loop needs less syntax than it looks like it needs, because bash defines it on one line and every later form is a way of handing that line a different word list. Expansion happens first, and the body then runs once per resulting word with the loop variable bound to the current word.

for name [ [in words ...] ; ] do commands ; done

Leave the in clause out and the loop walks the positional parameters instead, exactly as if you had written in “$@”. Ask for a list that expands to nothing and the body never runs at all.

That order, expand then bind then run, is the reason the list source is the first thing worth checking when a loop misbehaves.

#!/bin/bash
for server in web01 web02 db01
do
  echo "checking $server"
done

The script above takes its list from three literal words. I ran it unchanged and the body printed 3 lines, with a different name in the same echo line each time.

Terminal running a bash for loop over a word list, printing checking web01, web02 and db01
The same command runs three times, once per name in the list.

Nothing in the loop knows those words were typed by hand. A variable, a glob, or the output of a command would land in the same place, and that is where the trouble with lists starts.

The GNU bash manual states the same rule and adds one boundary, where a word list that expands to nothing runs no commands and the loop still returns zero. I read that wording before leaning on it for the glob case below.

What you need before the first loop

You need a bash shell, a file to put the loop in, and write permission in the directory the loop walks. Nothing on this page needs a newer shell than bash 3.2, which means every current Linux distribution and the macOS default can run it.

RequirementWhy it matters
bash 3.2 or newerBrace ranges, C-style counters, arrays and the read builtin all work from 3.2 onward
A script file or an interactive shellA loop that spans more than one line is tidier to keep in a file, and the shebang decides which shell runs it
Write permission in the target directoryA loop that renames or edits files needs permission on the directory as well as on the files
A decision about the list sourceLiterals, globs, arrays, variables and command output behave differently once a name contains a space

Confirm the version before trusting a copied example, because a shell that predates arrays will fail on the array line and report an unrelated error.

echo $BASH_VERSION

The output is the release string alone, with no distribution name attached. I confirmed mine before running anything on this page.

The word-list loop

The word-list form is the one to reach for until it stops fitting, because you can read the list straight off the line. Write the names between in and do, and the body runs once per name.

#!/bin/bash
for name in start stop restart status
do
  echo "action: $name"
done

The source decides whether a name with a space in it survives. That list can come from six different sources, and each one is expanded before the loop starts.

  • A literal list typed into the line, which has no expansion step at all.
  • A brace range such as {1..10}, which bash expands into numbers.
  • A glob such as *.log, which expands into whole matching filenames.
  • An array, expanded with the quoting rule covered below.
  • The output of a command, which bash splits on whitespace.
  • The positional parameters, used automatically when the in clause is missing.

Command output is the source that breaks first, because the split lands on whitespace inside a filename. A variable holding several names runs into the same split, and the shell script variables page covers how that string gets built in the first place.

#!/bin/bash
services="nginx ssh cron"
for service in $services
do
  echo "restarting $service"
done

I wrote the variable unquoted in that position on purpose, because the whitespace split is what turns one string into a list of names. Quoting the expansion inside the body is a separate decision, and it is the next thing to get right.

Counting loops three ways

Counting is the second reason people reach for a for loop, and bash offers three ways to build a list of numbers. They differ in where the numbers come from and whether that source can change while the script runs.

Brace expansion

A brace range expands into numbers before the loop runs, and a third value sets the step.

for n in {1..10..2}
do
  echo "n=$n"
done

Bash turns that line into 1 3 5 7 9, so the body repeats five times. A leading zero in the range pads every value, which is what makes {01..05} useful when the numbers end up in filenames.

The brace form cannot read a variable, because brace expansion happens before variable expansion. I checked it with a variable inside the range and the loop received that literal text as its single word.

seq

The seq command builds the numbers as a program, which lets a variable sit in any of the three positions.

for n in $(seq 1 3 9)
do
  echo "n=$n"
done

seq takes a start, a step and an end, and the command substitution hands the result to the loop as words. It is the right choice when the count comes from somewhere else in the script, so the loop and the number agree.

The C-style counter

Double parentheses switch bash into arithmetic, and the three expressions run as initialise, test and update.

for ((i = 1; i <= 3; i++))
do
  echo "attempt $i of 3"
done

The manual notes that an omitted expression behaves as 1, so a loop with all three expressions left blank is an honest infinite loop with no exit condition of its own.

Counting formSource can change while the script runsReach for it when
{1..10}NoThe numbers are fixed and you want the shortest line
seqYesThe start, the step or the end comes from a variable
for (( ))YesYou need a counter to test, update or print inside the body

Every row above builds a list of words, so the loop body never sees a number as anything other than text. A trap sits under the C-style row, where a counter used on its own line can abort a script under set -e.

A counter is also the usual way to build a report, such as counting the files in a directory by extension, where the loop decides the total and the print reports it.

Terminal output of a bash for loop over a brace range with a step of 2 and a seq range with a step of 3
Both counting forms walk an arithmetic sequence, with a different list behind it.

Looping over arrays, files, and script arguments

Arrays, globs and script arguments are the three sources a working script leans on, and each one carries a quoting rule. Getting the rule wrong produces a loop that runs, prints something plausible, and acts on the wrong name.

Arrays and the quoting rule

#!/bin/bash
hosts=("web server" "db server" "cache")
for h in "${hosts[@]}"
do
  echo "[$h]"
done

The quotes around the array expansion keep each element whole, and dropping them splits every element on whitespace before the loop body sees it. I ran the same array both ways and the unquoted run split both two-word host names.

Terminal comparing a quoted and an unquoted array expansion in a bash for loop
The quotes decide whether a two-word element stays one iteration or becomes two.
  • “${array[@]}” keeps each element as one word, spaces and all.
  • ${array[@]} without quotes splits every element on whitespace.
  • “${array[*]}” joins every element into a single word.

Globs over files

A glob expands into whole filenames, so a name containing a space survives this form intact. It is the natural first half of a bulk file task, and copying or removing files by extension uses the same expansion to pick its targets.

for f in *.txt
do
  echo "found: $f"
done

Each match arrives as one word, because pathname expansion runs after word splitting rather than before it. That is the one list source that handles an awkward filename without extra work.

Script arguments

A loop with no in clause walks the positional parameters, which makes it the shortest way to handle a script’s arguments.

#!/bin/bash
for arg
do
  echo "arg: $arg"
done

Run that script with three arguments and the body runs three times, and a quoted argument containing a space stays one word. Run it with none and the body never runs, with no error and a zero exit status.

I ran that script both ways and the empty case exits cleanly with no error.

Stopping a loop early with break and continue

The two builtins that change the flow of a running loop are ordinary bash keywords rather than loop syntax. The break and continue statements page covers the pair in full, and the two below are the ones a file loop needs.

A for loop is the right tool when the list already exists, and the while loop is the right tool when the end depends on a condition that can change inside the body.

Leave the loop with break

for n in 1 2 3 4 5
do
  if [ "$n" -eq 4 ]; then
    break
  fi
  echo "n=$n"
done

The body prints 1, 2 and 3 and stops before the fourth value, and the script continues on the line after done. The test inside it is an ordinary if else statement that runs on every pass.

Skip one value with continue

for n in 1 2 3 4 5
do
  if [ "$n" -eq 4 ]; then
    continue
  fi
  echo "n=$n"
done

continue ends the current pass and moves to the next word, so the loop prints 1, 2, 3 and 5. I reach for it when one item in my list needs to be left alone while the rest of the run continues.

Nested loops

Nested loops work the way you would expect, with the inner loop finishing for every value of the outer one. I ran that pair to confirm which loop a bare break leaves.

for host in web01 db01
do
  for port in 80 443
  do
    echo "$host:$port"
  done
done

A bare break inside the inner loop leaves only the inner loop, and the outer loop carries on with its next value. Passing a count changes the level it reaches.

Control wordWhat it does
breakLeaves the innermost loop that encloses it
continueMoves to the next word of that same innermost loop
break 2Leaves two levels at once, which is the short way out of a nested pair

Where the word list breaks

The word list is where a for loop stops being predictable, and each failure below comes from the same expansion step. None of them is a bug in bash, because each one is documented behaviour of unquoted expansion meeting a list nobody checked.

A filename with a space

Start with a file that lists two names, one of which contains a space, and loop over the output of cat.

# files.txt holds two lines:
#   report final.txt
#   notes.txt
for f in $(cat files.txt)
do
  echo "[$f]"
done

The loop prints 3 lines for 2 files, because bash splits the command output on whitespace and hands the loop report and final.txt as separate words. The command that consumes those words then works on a path that does not exist.

Reading a list of lines safely

The read builtin takes one line at a time and never consults the whitespace separator, which is exactly the behaviour a file of names needs.

while IFS= read -r f
do
  echo "[$f]"
done < files.txt

The -r flag stops the backslash in a name from being treated as an escape, and setting IFS empty keeps leading and trailing whitespace inside the line. I tested the same list through both loops, and only the read form kept the name whole.

Terminal showing a bash for loop splitting a filename on its space, and a while read loop keeping the whole line
Same file, same list: the for loop splits the name, the read loop keeps it.

A glob that matches nothing

An unmatched glob does not expand to an empty list, so a puzzling single iteration shows up.

shopt -s nullglob
for f in *.log
do
  echo "found: $f"
done

Without nullglob the asterisk arrives as the single word, so the body runs once with a filename that was never on disk. With nullglob set, the same line expands to nothing and the body runs zero times, and the manual confirms that the loop then returns zero.

The counter under set -e

A script that runs with set -e stops on the first command that returns a non-zero status, and arithmetic commands report their own result as that status.

set -e
i=0
((i++))
echo "this line never runs"

A post-increment on zero reports 1, so the shell reads the arithmetic as a failed command and exits before the next line. Guard the command or change the form when the counter can legitimately reach zero.

((i++)) || true

A rename loop that survives spaces

A batch rename is where a split name does damage that reading the output cannot undo, so build the list with a glob and print the moves before running them. The rename multiple files page covers the same task with dedicated tools, and the loop below is the one that keeps each name intact.

#!/bin/bash
set -euo pipefail
for f in *.jpeg
do
  new="${f%.jpeg}.jpg"
  echo "mv -- $f $new"
done

The echo line is the dry run, and reading it is what turns the loop from a guess into a checked change. The name on the right comes from stripping the suffix at the end of the string, which keeps the rest of the filename untouched.

Terminal printing the planned mv commands for a batch JPEG rename before running them
The dry run prints every move before anything is renamed.

I compared the printed moves against the directory listing before letting the loop touch anything.

Swap the echo for the move to run the same loop against the files, and keep the quotes on both names so a space cannot split them.

#!/bin/bash
set -euo pipefail
for f in *.jpeg
do
  new="${f%.jpeg}.jpg"
  mv -- "$f" "$new"
done

How the list is built decides what the body receives, and both halves of the rename rest on it. The glob handed over whole filenames, the quotes carried each one through unchanged, and nothing else in the loop had to compensate.

Print the list before you trust the body

The body of a for loop is only as good as the list that feeds it, and the list is one unquoted expansion away from being wrong. A loop that prints its words first turns a silent rename into a visible one.

printf '%s\n' "${files[@]}"

My check before any loop that touches files is one printf on the list. It prints a line per word, which is enough to see a filename arrive in two pieces before any command acts on it.

Both of those directions are worth taking once the list is right. Move the body into a shell function when it grows past a handful of lines, and put the finished script under cron when it should run on its own.

FAQ

Why did my for loop run once when the directory was empty?

A glob with no match expands to the glob text itself, so the loop receives one word and runs the body once. Run shopt -s nullglob and the same line expands to nothing, so the body runs zero times.

How do I loop over filenames that contain spaces?

Build the list from a glob rather than from the output of ls, and quote the variable inside the body. for f in *.txt keeps each filename whole, while for f in $(ls) splits any name that contains a space.

How do I break out of two nested for loops?

Pass a count to break. A bare break leaves only the innermost loop, and break 2 leaves both levels at once. The same count argument works on continue.

Should I use for or while read to loop over the lines of a file?

Use while IFS= read -r for the lines of a file, because it keeps each line whole and leaves the whitespace alone. Use for when the words come from a literal list, a brace range, or a glob.