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:
for variable in list: Suitable for scenarios with a known list or known number of iterations. Can iterate over numbers, files, command output, and arrays.while condition: Suitable for scenarios where you loop based on a condition. Usewhile readfor processing files line by line.until condition: Semantically the opposite ofwhile, suitable for "waiting for something to happen" scenarios.breakandcontinuecontrol the loop flow. In multi-level loops, you can usebreak n.
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.shfirst.