Bash — The for Loop

Learn how Bash’s for loop automates repetitive tasks, from counting through numbers to processing files, arrays, and command-line arguments. Explore both loop styles, practical examples, and common pitfalls.

Bash — The for Loop

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:

  • for begins the loop.
  • variable stores the current value.
  • in introduces the list of values to process.
  • do marks the beginning of the loop body.
  • The commands between do and done execute during each iteration.
  • done marks 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.

ExpressionPurposeExample
EXP1Initializes the loopi=1
EXP2Determines whether another iteration should runi<=5
EXP3Updates the value after each iterationi++

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.

  1. Bash initializes i to 1.
  2. It checks whether i is less than or equal to 5.
  3. If the condition succeeds, Bash executes the commands inside the loop.
  4. After the commands finish, Bash evaluates i++, increasing i by one.
  5. 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.

SituationRecommended approach
Repeat a task for a numeric counterArithmetic for
Process a fixed list of namesList-based for
Process every matching fileList-based for with a filename pattern
Iterate through an arrayList-based for with "${array[@]}"
Process every command-line argumentList-based for with "$@"
Repeat while a condition remains trueConsider 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.

Read next

Bash — The declare Built-in

Learn how Bash’s declare built-in lets you control variable attributes, create arrays, define read-only or integer variables, export values, and inspect how Bash treats your variables.

Bash — The fg Built-in

Learn how Bash’s fg built-in brings background and suspended jobs back to the foreground. Discover how to use fg, bg, and jobs, understand job numbers, and manage running commands without restarting them.