跪拜 Guibai
← Back to the summary

Shell Loops That Actually Work: for, while, until, and When to Use Each

"Quick Mastery of Shell" Shell Loops and Iteration

Writing scripts is essentially about making machines do "repetitive labor" for us. We've previously discussed variables, conditionals, and functions. These things are just tools on their own, but the key that truly transforms a script from "one-off" to "automated" is the loop. In other words, without loops, even the smartest judgment can only run once, and the fanciest function can only process a single piece of data. Loops are the engine of a script, and iteration is the most common application scenario for this engine.

In this article, we'll go over several common loop structures and iteration methods in the shell.

1. The Concepts of Loops and Iteration

These two words often appear together, but they are actually two different things. A loop is a control flow mechanism that makes a block of code execute repeatedly; iteration is a data operation that takes elements from a collection and looks at them one by one. When writing shell scripts, we are more often "iterating within a loop," so it's not surprising that many people use these two concepts interchangeably.

Let's take the simplest example: batch renaming .log files in a directory to .log.bak. For one file, you'd write a single mv command; but what about a hundred files? Writing mv one by one is obviously impractical, and that's when you need a loop. You make the shell execute mv in a "loop" over these hundred files, processing one file per cycle—this process of "processing one file at a time" is iteration.

So, a loop is the means, and iteration is one of the ends. In the shell, the three most commonly used loops are: for, while, and until. Let's look at them one by one.

2. The for Loop

The for loop is the most straightforward type of loop in the shell. Its logic is very clear: given a list, execute the loop body once for each element in the list. Therefore, when you know how many times to loop or know exactly which data to process, for is the most convenient choice.

2.1 The Most Basic Syntax

#!/bin/bash
for i in 1 2 3 4 5
do
    echo "The current number is: $i"
done

Execution result:

The current number is: 1
The current number is: 2
The current number is: 3
The current number is: 4
The current number is: 5

This code assigns i sequentially to 1, 2, 3, 4, 5, executing the echo between do and done each time. The part after in is the "list," with elements separated by spaces, and one element is taken and assigned to i per cycle. The advantage of this syntax is its intuitiveness, but the downside is also obvious—it becomes tedious to write when there are many elements.

2.2 Generating Number Sequences with Brace Expansion

If the list is a sequence of consecutive numbers, there's no need to write them out one by one. The shell provides brace expansion:

#!/bin/bash
for i in {1..5}
do
    echo "Number: $i"
done

{1..5} automatically expands to 1 2 3 4 5, producing the same effect as the previous example but more concise. You can even specify a step:

for i in {0..10..2}
do
    echo "i = $i"
done

This {0..10..2} means start from 0, increment by 2 each time, and end at 10, resulting in 0, 2, 4, 6, 8, 10.

However, this brace expansion method is not supported in some older versions of sh and may also fail in stripped-down environments like dash or busybox. If you need to write highly compatible scripts, it's recommended to use seq.

2.3 Generating Sequences with seq

seq is a small utility that comes with Linux, specifically designed for generating number sequences:

#!/bin/bash
for i in $(seq 1 5)
do
    echo "i = $i"
done

seq 1 5 outputs 1 2 3 4 5, equivalent to {1..5}. But seq is more flexible than brace expansion; it supports negative numbers and reverse order:

for i in $(seq 10 -1 1)
do
    echo "Countdown: $i"
done

seq 10 -1 1 means start from 10, decrement by 1 each time, and end at 1. This is a simple countdown. seq can also specify the number width:

for i in $(seq -w 1 10)
do
    echo "Number: $i"
done

The -w option pads all numbers with leading zeros, outputting 01 02 03 ... 10, which is suitable for generating aligned numbers.

2.4 C-style for Loop

If you've written C, Java, or JavaScript, the following syntax will look very familiar:

#!/bin/bash
for ((i=1; i<=5; i++))
do
    echo "i = $i"
done

This is the C-style loop provided by the shell. Note the double parentheses—it's not the (()) arithmetic operation, but for ((...)). Inside, i=1 is the initial condition, i<=5 is the loop condition, and i++ is the change after each cycle. This syntax is suitable for scenarios where the number of loops is determined by a counter, such as iterating over array indices or doing accumulations.

But honestly, this syntax is not used much in shell scripting because shell is not inherently strong at handling numbers. If something can be solved with for i in, people generally won't write for ((...)). Its significance lies more in providing a familiar entry point for programmers transitioning from the C family.

2.5 Iterating Over Files

The most powerful aspect of the for loop is that the "list" can be the output of any command. This means you can make it automatically find all files to be processed:

#!/bin/bash
for file in *.log
do
    echo "Processing: $file"
    mv "$file" "$file.bak"
done

This script renames all .log files in the current directory to .log.bak. * is a wildcard; the shell expands *.log into all matching filenames before executing the for loop.

It's best to wrap filenames in quotes—"$file", not $file. If a filename contains spaces, not using quotes will cause problems.

Similarly, you can use find to locate files matching certain criteria:

#!/bin/bash
for file in $(find /var/log -type f -name "*.log")
do
    echo "Found log: $file"
done

But there's a pitfall here: find output is separated by newlines by default, and the shell's for splits based on IFS. If a filename contains spaces or newlines, it will break. In production environments where you need to handle arbitrary filenames, it's recommended to honestly use while read, which we'll cover later.

2.6 Iterating Over Arrays

In a previous article, we discussed shell array usage. Combined with a for loop, you can iterate over all elements of an array:

#!/bin/bash
fruits=("Apple" "Banana" "Orange" "Grape")

for fruit in "${fruits[@]}"
do
    echo "I like to eat: $fruit"
done

"${fruits[@]}" is the standard way to get all elements of an array. Double quotes are mandatory here—without them, if an element itself contains a space (like "Red Apple"), it will be split into two elements.

If you only want to get a subset of elements, you can use index slicing:

for fruit in "${fruits[@]:1:2}"
do
    echo "Slice: $fruit"
done

${fruits[@]:1:2} means start from index 1 and take 2 elements, which are "Banana" and "Orange".

If the array is an associative array (key-value form), you need to iterate like this:

#!/bin/bash
declare -A user
user=([name]="Zhang San" [age]=25 [city]="Beijing")

for key in "${!user[@]}"
do
    echo "$key = ${user[$key]}"
done

The ! in "${!user[@]}" retrieves all keys. This is a special syntax in the shell that might seem odd at first glance, but just remember it.

3. The while Loop

The for loop is suitable for "known list" scenarios, but often we don't know in advance how many times to loop—for example, "keep waiting as long as the service hasn't started" or "keep retrying as long as the file hasn't finished downloading." In these cases, you need while.

3.1 Basic Syntax

The logic of while is: as long as the condition is true, keep looping.

#!/bin/bash
count=1
while [ $count -le 5 ]
do
    echo "count = $count"
    count=$((count + 1))
done

Execution result:

count = 1
count = 2
count = 3
count = 4
count = 5

The condition [ $count -le 5 ] means "count is less than or equal to 5"; -le is short for less or equal. This single bracket conditional syntax in the shell is the classic test command format. There must be spaces on both sides of the brackets, and spaces between the variables and operators inside.

The most important line in the loop body is count=$((count + 1)), which increments count by 1 each time. This step must never be forgotten—without this line, count remains 1 forever, the condition is always true, and the loop runs infinitely. This kind of "infinite loop" is the most common mistake when writing while loops.

$((...)) is the shell's arithmetic expansion. $((count + 1)) means calculate the value of count + 1. It can also be written as ((count++)), which has the same effect but is more compact.

3.2 Several Ways to Write an Infinite Loop

Sometimes we intentionally want an infinite loop, such as when writing a daemon process that needs to run continuously. There are three common ways to write it:

# Method 1
while true
do
    echo "Keep running"
    sleep 1
done

# Method 2
while :
do
    echo "Keep running"
    sleep 1
done

# Method 3
while [ 1 -eq 1 ]
do
    echo "Keep running"
    sleep 1
done

true and : are shell built-in commands, both meaning "always return true," and are equivalent. The third method, [ 1 -eq 1 ], is the test form, which is more verbose but more intuitive. In a true infinite loop, you must remember to add sleep or some exit condition, otherwise it will max out the CPU.

3.3 Reading a File Line by Line

The killer application of the while loop is reading a file line by line. For example, suppose we have a name list file users.txt:

Zhang San
Li Si
Wang Wu

Now we want to send a welcome email to each person:

#!/bin/bash
while read -r line
do
    echo "Sending welcome email to: $line"
done < users.txt

read -r line reads one line from standard input and assigns it to line. The -r option means not to treat backslashes as escape characters, reading them literally. < users.txt redirects users.txt to the standard input of the while loop—this step is crucial; without it, read has nothing to read.

The biggest advantage of this approach is that it won't break due to spaces in filenames or content. As we mentioned earlier, using for with find can fail when encountering spaces, but while read reads line by line, where each line is a complete record, stably handling whatever characters are inside. Therefore, for file processing, it is strongly recommended to use while read instead of for.

If you want to read multiple fields simultaneously, for example, if each line is in the format Name City Age, you can do this:

while read -r name city age
do
    echo "$name is from $city, $age years old this year"
done < users.txt

read automatically splits based on IFS, assigning the split fields to the subsequent variables.

3.4 Preserving Spaces When Reading Files

read by default uses IFS to split fields within a line. This means if a line contains "Zhang San Beijing 25" (with extra spaces), name will get "Zhang San", but the extra spaces will be swallowed.

If you want to preserve the original format, you can temporarily change IFS before the loop:

#!/bin/bash
OLD_IFS="$IFS"
IFS="|"
while read -r name city age
do
    echo "name=[$name] city=[$city] age=[$age]"
done < users.txt
IFS="$OLD_IFS"

| is the field delimiter (you can change it to another character that won't appear in the file). This way, each line is split by |. Remember to restore IFS after the loop ends, otherwise it will pollute the current shell.

4. The until Loop

while loops as long as the condition is true; until does the opposite—it loops as long as the condition is false. These two are mirror images of each other and can be logically converted:

# while syntax
while [ $count -le 5 ]
do
    echo $count
    count=$((count + 1))
done

# until syntax
until [ $count -gt 5 ]
do
    echo $count
    count=$((count + 1))
done

while says "loop while less than or equal to 5"; until says "stop when greater than 5". The two conditions are exactly opposite, so the output is the same.

So what is the point of until? Mainly for scenarios where it's semantically more fitting. For example, "waiting for a service to start":

#!/bin/bash
until curl -s http://localhost:8080/health > /dev/null
do
    echo "Service isn't up yet, waiting a second to retry"
    sleep 1
done
echo "Service is up"

Here, when curl fails, it returns non-zero (i.e., "false"), the until condition is met, and the loop continues; when curl succeeds, it returns 0, the condition becomes "false," and the loop ends. This reads more naturally than while ! curl ....

until is not used much, but occasionally it can make the code read more naturally. Just knowing it exists is enough.

5. Loop Control

When writing loops, you often encounter situations where you want to exit early halfway through, or skip a particular iteration and jump directly to the next round. break and continue are for this.

5.1 continue Skips the Current Iteration

The role of continue is to end the current iteration of the loop and immediately start the next one.

#!/bin/bash
for i in {1..5}
do
    if [ $i -eq 3 ]
    then
        continue
    fi
    echo "i = $i"
done

Output:

i = 1
i = 2
i = 4
i = 5

You can see that the iteration for i=3 produced no output and jumped directly to the next round. Any code after continue will also not execute.

A common use case is "skipping certain special values." For example, skipping hidden files when iterating over a directory:

for file in *
do
    if [[ "$file" == .* ]]
    then
        continue
    fi
    echo "Processing: $file"
done

5.2 break Exits the Entire Loop

break is more drastic than continue—it directly ends the entire loop.

#!/bin/bash
for i in {1..5}
do
    if [ $i -eq 3 ]
    then
        break
    fi
    echo "i = $i"
done

Output:

i = 1
i = 2

i=3 triggers break, the entire loop ends immediately, and 4 and 5 never get a chance to output.

A typical scenario is "exit once the target is found":

for file in *.log
do
    if grep -q "ERROR" "$file"
    then
        echo "Found error in $file"
        break
    fi
done

This script only cares about the first log file containing ERROR. It exits once one is found, without wasting time continuing to search.

5.3 break n in Multi-level Loops

If loops are nested, break by default only exits one level. Sometimes you want to jump out of all levels from an inner loop directly, and you can add a number:

#!/bin/bash
for i in {1..3}
do
    for j in {1..3}
    do
        if [ $j -eq 2 ]
        then
            break 2
        fi
        echo "i=$i, j=$j"
    done
done

break 2 means exit 2 levels of loops. Execution result:

i=1, j=1

Only one line is output. Because when j=2, it directly jumps out of the outer loop, ending even the outer level. continue 2 works similarly, meaning skip the remaining part of that iteration of the outer loop.

6. A Few Practical Examples

Just looking at the syntax is easy to forget. The following examples use several of the loops we've discussed. It's recommended to type them out yourself.

6.1 Summing 1 to 100

#!/bin/bash
sum=0
for i in $(seq 1 100)
do
    sum=$((sum + i))
done
echo "The sum from 1 to 100 is: $sum"

Output: 5050. This problem can be solved with either for or while, but using seq with for is the cleanest.

6.2 9x9 Multiplication Table

#!/bin/bash
for i in $(seq 1 9)
do
    for j in $(seq 1 $i)
    do
        echo -n "$j*$i=$((j*i))  "
    done
    echo
done

echo -n does not add a newline. The echo after the inner loop ends is used to add a newline. This example mainly demonstrates the use of nested for loops.

6.3 Monitoring a Service Until It Starts

#!/bin/bash
max_wait=30
count=0
until curl -s http://localhost:8080/health > /dev/null
do
    count=$((count + 1))
    if [ $count -ge $max_wait ]
    then
        echo "Waited ${max_wait} seconds and the service still isn't up, giving up"
        exit 1
    fi
    echo "Service not ready, waiting... ($count/$max_wait)"
    sleep 1
done
echo "Service is ready"

The combination of until + break (using exit instead) adds a timeout limit to the wait, preventing an infinite wait.

6.4 Batch Renaming Files

#!/bin/bash
for file in *.jpg
do
    [ -e "$file" ] || continue   # Skip if no .jpg files exist
    newname="${file%.jpg}_processed.jpg"
    mv "$file" "$newname"
    echo "Renamed: $file -> $newname"
done

${file%.jpg} is shell string manipulation—it removes the trailing .jpg from $file, and then _processed.jpg is appended. This approach is faster and more stable than using sed.

6.5 Reading a CSV and Calculating Statistics

#!/bin/bash
total=0
count=0
while IFS=, read -r name age city
do
    # Skip the header
    if [ "$name" = "name" ]
    then
        continue
    fi
    total=$((total + age))
    count=$((count + 1))
    echo "$name is from $city"
done < users.csv
echo "Total people: $count, Average age: $((total / count))"

This example strings together while read, IFS modification, and continue, representing a very common pattern in daily scripting.

7. Summary

At this point, we've covered the most commonly used loops and iteration methods in the shell. Let's review:

Mastering these few concepts is basically enough for writing daily operations scripts and data processing scripts. In the next article, we'll continue and look at string processing and regular expression matching in the shell, which is where the shell's true productivity shines.


All scripts in this article have been tested in a bash 4.x environment and are theoretically compatible with bash 3.2+. If your environment is dash, ash, or ksh, some syntax may require minor adjustments. It is recommended to run them with bash script.sh first.