Imagine you have a hundred files to process, ten servers to check, or a list of packages to install. You could write a separate command for every item, of course. But then you would spend more time writing commands than doing anything useful.
Fortunately, Bash has a better idea: loops.
A loop allows you to execute the same commands repeatedly, changing the values or conditions as the script progresses. One of the most commonly used loops in Bash is for.
With a for loop, you can process a list of values, work through files in a directory, iterate over command-line arguments, or repeat a task a specific number of times. Bash also provides an arithmetic form of for that will look familiar if you have used languages such as C, C++, or Java.
Let's explore both forms and see how they work in practice.
1. What Is a for Loop?
A for loop repeats a block of commands for a series of values. During each iteration, a variable receives the next value, and the commands inside the loop execute.
A basic Bash for loop looks like this:
for variable in value1 value2 value3
do
echo "$variable"
done
When Bash executes this loop, it assigns each value to variable in turn. The echo command prints the current value, so the output is:
value1
value2
value3
Let's examine the structure:
forbegins the loop.variablestores the current value.inintroduces the list of values to process.domarks the beginning of the loop body.- The commands between
doanddoneexecute during each iteration. donemarks the end of the loop.
The variable name is your choice. You could call it i, item, file, server, or almost anything else that follows Bash's variable-naming rules.
The do and done keywords are required parts of the loop syntax. You can put do on the same line as the loop declaration or on the following line. Both styles are valid:
for item in apple banana cherry; do
echo "$item"
done
Personally, I find placing do on its own line makes longer loops easier to read, but consistency matters more than the particular style you choose.
2. The Arithmetic for Loop
Let's start with the form that resembles the for loop found in C-like programming languages.
Its general syntax is:
for (( EXP1; EXP2; EXP3 ))
do
commands
done
The three expressions inside the double parentheses control how the loop operates.
| Expression | Purpose | Example |
|---|---|---|
EXP1 | Initializes the loop | i=1 |
EXP2 | Determines whether another iteration should run | i<=5 |
EXP3 | Updates the value after each iteration | i++ |
Here is a complete example:
#!/usr/bin/env bash
for (( i=1; i<=5; i++ ))
do
echo "Welcome $i times"
done
The output is:
Welcome 1 times
Welcome 2 times
Welcome 3 times
Welcome 4 times
Welcome 5 times
Let's walk through what happens.
- Bash initializes
ito1. - It checks whether
iis less than or equal to5. - If the condition succeeds, Bash executes the commands inside the loop.
- After the commands finish, Bash evaluates
i++, increasingiby one. - Bash checks the condition again and repeats the process until the condition is no longer satisfied.
When i reaches 6, the condition i<=5 is false, so the loop ends.
Notice that the arithmetic loop uses (( ... )), with two opening and two closing parentheses. This is Bash arithmetic syntax, not the ordinary single-parenthesis form used for grouping commands.
Changing the counter
Bash supports several ways to modify a numeric variable in an arithmetic loop.
For example, you can increment a counter using i++ or i+=1, and decrement it using i--.
This example counts backward:
for (( i=5; i>=1; i-- ))
do
echo "$i"
done
echo "Liftoff!"
The result is a countdown from five to one, followed by Liftoff!.
You can also increment by a larger amount:
for (( i=0; i<=10; i+=2 ))
do
echo "$i"
done
This prints the even numbers from 0 through 10.
Arithmetic loops are especially useful when you need precise control over a counter, a range of numeric values, or the number of repetitions.
One important detail: the arithmetic expressions use Bash's integer arithmetic. They are not intended for floating-point calculations.
3. The List-Based for Loop
The second form of for is often even more useful in everyday shell scripting. Instead of defining initialization, a condition, and an increment, you provide a list of values.
Its general structure is:
for VARIABLE in value1 value2 value3
do
commands
done
Bash assigns each item in the list to VARIABLE and executes the loop body.
For example:
#!/usr/bin/env bash
for fruit in apple banana orange grape
do
echo "I like $fruit"
done
The output is:
I like apple
I like banana
I like orange
I like grape
There is no need to maintain a counter or tell Bash when to stop. The loop finishes automatically when it has processed every item.
This form is ideal when you already have a collection of values to work with.
4. Using Brace Expansion to Generate a Sequence
What if you need to process numbers from 1 to 10? Writing every number by hand would be rather tedious.
Bash offers a convenient feature called brace expansion, which can generate sequences before the loop executes.
For example:
echo {1..9}
Produces:
1 2 3 4 5 6 7 8 9
Brace expansion can also generate sequences of letters:
echo {a..z}
This prints the letters from a through z.
You can use the same feature in a for loop:
#!/usr/bin/env bash
for i in {1..5}
do
echo "Welcome $i times"
done
The output is the same as in our earlier arithmetic example:
Welcome 1 times
Welcome 2 times
Welcome 3 times
Welcome 4 times
Welcome 5 times
For a simple, fixed sequence, brace expansion makes the code short and readable.
Specifying a step
Brace expansion can also generate sequences with a specified increment:
for i in {0..20..5}
do
echo "$i"
done
The output is:
0
5
10
15
20
The general form is {start..end..step}. You can use descending sequences too:
for i in {5..1..1}
do
echo "$i"
done
This prints 5, 4, 3, 2, and 1.
There is one limitation to remember: brace expansion happens before the loop starts. A range such as {1..5} is expanded by Bash into a list of values; it is not a dynamic loop condition.
For a sequence that depends on a variable calculated at runtime, an arithmetic loop is often more appropriate.
For example:
limit=7
for (( i=1; i<=limit; i++ ))
do
echo "$i"
done
This loop uses the current value of limit when checking whether another iteration should run.
5. Looping Through Files in a Directory
One of the most common uses of a for loop is processing files.
Suppose you want to list every .log file in the current directory. You could write:
#!/usr/bin/env bash
for file in *.log
do
echo "$file"
done
Bash expands *.log into the names of matching files before starting the loop. The loop then processes each resulting pathname.
This is usually better than running ls and trying to split its output into filenames.
Consider the following older approach:
for file in $(ls ./)
do
echo "$file"
done
Although it may appear to work in a directory containing simple filenames, it can fail when filenames contain spaces, tabs, or newlines. Command substitution also removes trailing newlines, and the resulting text undergoes word splitting and pathname expansion.
In other words, a file called monthly report.log may be treated as two separate items. That is not particularly helpful when your script is supposed to process files rather than invent new ones.
For ordinary file iteration, use Bash's filename expansion directly:
for file in ./*.log
do
echo "$file"
done
The ./ prefix also makes it clear that the paths refer to the current directory.
What if no files match?
By default, if no filename matches a pattern such as *.log, Bash leaves the pattern unchanged. Your loop may therefore run once with the literal value ./*.log.
If you want the loop to do nothing when no files match, you can enable the nullglob shell option:
#!/usr/bin/env bash
shopt -s nullglob
for file in ./*.log
do
echo "$file"
done
With nullglob enabled, a pattern with no matches expands to an empty list, so the loop body is not executed.
Another option is to test whether the expanded path refers to a regular file:
for file in ./*.log
do
[[ -f "$file" ]] || continue
echo "Processing $file"
done
This skips unmatched patterns and entries that are not regular files. It is a useful safeguard when you want to keep the default shell behavior unchanged.
6. Looping Through an Array
Bash arrays are another natural fit for list-based loops.
An indexed array stores multiple values under one variable name:
#!/usr/bin/env bash
servers=("web01" "web02" "database01")
for server in "${servers[@]}"
do
echo "Checking server: $server"
done
The expression "${servers[@]}" expands to the array's individual elements, preserving each element as a separate argument even when an element contains spaces.
For example:
tasks=("Update packages" "Restart service" "Check logs")
for task in "${tasks[@]}"
do
echo "Task: $task"
done
Each task remains one item, even though its value contains spaces.
This is a particularly useful pattern for scripts that process predefined lists of servers, packages, tasks, or configuration values.
7. Looping Through Command-Line Arguments
A script often receives its input through command-line arguments. Bash provides a convenient way to process all of them using "$@".
For example, save the following as show-arguments.sh:
#!/usr/bin/env bash
for argument in "$@"
do
echo "Argument: $argument"
done
Run it like this:
./show-arguments.sh alpha "beta gamma" delta
The output is:
Argument: alpha
Argument: beta gamma
Argument: delta
The quoted "$@" expansion preserves each command-line argument as a separate item. This is important because an argument can contain spaces and still needs to be treated as one value.
This pattern is useful for command wrappers, deployment scripts, and utilities that need to process an arbitrary number of arguments.
8. Using break and continue Inside a Loop
Sometimes you don't want to process every item in the list.
Bash provides two useful commands for controlling loop execution: break and continue.
The break command terminates the loop immediately:
for i in {1..10}
do
if (( i == 5 )); then
break
fi
echo "$i"
done
The output is:
1
2
3
4
Once i reaches 5, Bash executes break and leaves the loop.
The continue command behaves differently. It skips the remainder of the current iteration and moves on to the next one:
for i in {1..5}
do
if (( i == 3 )); then
continue
fi
echo "$i"
done
The output is:
1
2
4
5
The value 3 is skipped, but the loop continues processing the remaining values.
These commands are particularly useful when filtering items or stopping a search as soon as a particular condition is met.
9. A Practical Example: Processing Log Files
Let's put several of these ideas together.
Imagine you have a directory containing application log files and want to display a message for each file that is not empty.
#!/usr/bin/env bash
shopt -s nullglob
for file in ./*.log
do
if [[ ! -s "$file" ]]; then
echo "Skipping empty file: $file"
continue
fi
echo "Processing log file: $file"
# Add log-processing commands here.
done
The -s file test checks whether a file exists and has a size greater than zero. The ! operator reverses the result, so the condition succeeds when the file is missing or empty.
Because the loop uses nullglob, it does not process a literal ./*.log pattern if no matching files exist.
For each matching pathname, Bash checks the file and skips it if the test succeeds. Otherwise, the script reaches the processing section.
This example demonstrates an important principle: loops become much more useful when combined with conditionals. A for loop determines which items to visit, while an if statement determines what to do with each item.
10. Common Mistakes to Avoid
Even though for loops are straightforward, a few mistakes can cause confusing behavior.
Forgetting do or done
Every loop needs its opening and closing keywords:
for item in one two three
do
echo "$item"
done
Without do or done, Bash will report a syntax error.
Forgetting to quote variables
When working with filenames or values that may contain spaces, quote the variable:
for file in ./*.log
do
echo "$file"
done
And when passing a variable to another command:
for file in ./*.log
do
cp -- "$file" "/backup/"
done
The quotes preserve the filename as a single argument. The -- marks the end of command options for cp, helping prevent a pathname beginning with a hyphen from being interpreted as an option. The destination directory must already exist for this example to work as intended.
Confusing brace expansion with variable expansion
This does not create a dynamic range:
limit=5
for i in {1..$limit}
do
echo "$i"
done
Brace expansion happens before ordinary variable expansion, so Bash does not use the value of limit to construct the range.
Use an arithmetic loop instead:
limit=5
for (( i=1; i<=limit; i++ ))
do
echo "$i"
done
Using a list loop when a condition-based loop is needed
A list-based for loop is ideal when you have a collection of items or a known sequence. If you need to continue until a condition changes, a while loop may be a better fit.
For example, waiting for a service to become available is usually a task for while, not a fixed list of values. We will explore that type of loop separately.
11. Which Form Should You Use?
Both forms of for are useful, but they solve slightly different problems.
| Situation | Recommended approach |
|---|---|
| Repeat a task for a numeric counter | Arithmetic for |
| Process a fixed list of names | List-based for |
| Process every matching file | List-based for with a filename pattern |
| Iterate through an array | List-based for with "${array[@]}" |
| Process every command-line argument | List-based for with "$@" |
| Repeat while a condition remains true | Consider a while loop |
The best choice is the one that makes your intention obvious to the next person reading the script—including you, six months later, when you have forgotten why that particular loop exists.
Conclusion
The Bash for loop is a simple but powerful way to automate repetitive work. Its arithmetic form gives you precise control over counters and numeric conditions, while its list-based form makes it easy to process values, arrays, filenames, and command-line arguments.
The basic structure is easy to remember:
for item in list
do
commands
done
For numeric counters, use the arithmetic form:
for (( i=1; i<=5; i++ ))
do
commands
done
Once you understand these two patterns, you can replace repetitive commands with compact, reusable scripts. Combine loops with conditionals, arrays, and Bash's filename expansion, and you will have the foundation for automating a surprising amount of everyday Linux administration.
After all, if a computer can repeat the same task a thousand times without getting bored, there is no reason you should have to.