1. Introduction

There are multiple ways to loop over a range of numeric values in Linux scripts. However, it’s important to know the specific use case for each looping construct. Then, we can apply the most suitable construct for a particular scenario.

In this tutorial, we’ll examine the differences between Bash loop counters and sequence iteration using brace expansion and the seq command.

2. Numeric Sequences in Bash

When we use loop counters, we have complete control over the operations performed on the counter variable:

$ COUNTER=1
$ N=5
$ while [[ $COUNTER -le $N ]]; do
    echo $COUNTER;
    COUNTER=$(( $COUNTER + 1 ));
done;
1
2
3
4
5

In the above output, we use a while loop to print the numbers from 1 to 5.

Similarly, we can generate a numeric sequence for iterating over each value. In Bash, we can obtain this type of sequence as a string containing numeric values separated by a delimiter, such as a whitespace or a newline character. As Bash variables and values are untyped, the shell can use such strings as a list of numeric values.

Now, let’s explore two ways of generating such sequences without using loop counters.

2.1. The Sequence Expression

Perhaps the simplest way of generating a numeric sequence in Bash is using the sequence expression within a brace expansion construct. Its syntax consists of curly braces containing the start, end (inclusive), and increment step, each separated by two dots:

$ echo {1..5..2}
1 3 5

Here, we specified the start, end, and step as 1, 5, and 2 respectively. Thus, we get 1 (+2) 3 (+2) 5.

If we don’t pass a step, its default value is taken as 1. Whether the step should be positive or negative is determined based on whether the start value is less than or greater than the end respectively:

$ echo {1..5} with default step 1 and {5..1} with default step -1
1 2 3 4 5 with default step 1 and 5 4 3 2 1 with default step -1

Here, both {1..5} and {5..1} were expanded. The first one printed the values from 1 to 5 in ascending order with a step value of one. The second one printed the same numbers in descending order with a step value of -1.

Furthermore, we can also supply negative integers:

$ echo {-5..-1}
-5 -4 -3 -2 -1

Here, the expression expanded as a sequence from -5 to -1.

2.2. The seq Command

The seq command returns a string containing a sequence of numbers within the provided range from start to end (inclusive), along with an increment step in the middle:

$ seq 1 2 5         
1
3
5

The values of the start and step are 1 by default. So, if we skip these two parameters, we’ll get the output as natural numbers till the specified end (inclusive):

$ seq 5    
1
2
3
4
5

So, seq 5 prints the values from 1 to 5, each on a new line.

Let’s try passing negative values:

$ seq -5 -1 -10    
-5
-6
-7
-8
-9
-10

As shown above, seq printed the numbers from -5 to -10 with a step value of -1 without issues.

3. Looping Structure Applicability

Bash has multiple looping structures:

  • for loop iterates over a list of provided values
  • while loop runs as long as its condition expression evaluates to true
  • until loop runs as long as its condition expression evaluates to false
  • C-style for, while, and until loops use double parentheses to perform C-style arithmetic operations without the $ symbol

Among these loop structures, the first for loop supports brace expansion and seq.

On the other hand, we can use loop counters only in other looping structures. This is because of the need to use condition expressions for evaluating loop counters.

4. Behavior in Edge Cases

Let’s consider a couple of edge cases. We’ll inspect how the seq and brace expansion constructs behave for each one.

4.1. Equal Start and End

For this edge case, when using a loop counter, we can specify whether the condition expression should evaluate to true:

$ COUNTER=5
$ while [[ $COUNTER -le 5 ]]; do
    echo $COUNTER;
    COUNTER=$(( $COUNTER + 1 ));
done;
5
$ COUNTER=5
$ while [[ $COUNTER -lt 5 ]]; do
    echo $COUNTER;
    COUNTER=$(( $COUNTER + 1 ));
done;

As shown above, the less-than-or-equal-to condition with -le passed the check. So, we saw the output once. This wasn’t the case when we just used the less-than condition with -lt.

Let’s take a look at how the brace expansion and seq work in this scenario:

$ for VAL in {5..5}; do
    echo $VAL;
done;
5
$ for VAL in $(seq 5 5); do
    echo $VAL;
done;
5

As we see above, brace expansion and seq both print the start or end when the latter are equal. The step wasn’t specified, so its value was assumed to be one by default. Additionally, we need command substitution to use seq in the loop.

4.2. Start Greater Than End With a Positive Step

Now, let’s check what happens for this edge case in all three constructs. We specify the values of the start, end, and step as 10, 5, and 3 respectively:

$ COUNTER=10
$ while [[ $COUNTER -le 5 ]]; do
    echo "Using counter $COUNTER";
    COUNTER=$(( $COUNTER + 3 ));
done;
$ for VAL in $(seq 10 3 5); do
    echo " Using seq $VAL";
done;

$ for VAL in {10..5..3}; do
    echo "Using brace expansion $VAL";
done;
Using brace expansion 10
Using brace expansion 7

Both the seq and the loop counter cases didn’t display any output but brace expansion did. This implies that Bash considered the step as negative.

5. Behavior on Invalid Syntax

Brace expansion doesn’t throw any error when its syntax is invalid. The shell assumes the expression isn’t a brace expansion and returns that invalid expression as a string. This isn’t the case when using loop counters or seq.

Let’s check the output of running an invalid brace expansion expression:

$ for VAL in {5..5..x}; do
    echo $VAL;
done;
{5..5..x}

Here, {5..5..x} is invalid because the step must be an integer. Bash used the given expression as a string. The loop continued to run.

Being a command, seq* has a non-zero exit code, returns an empty string, and logs an error to *stderr:

$ for VAL in 0 1 $(seq 5 0 10); do
    echo $VAL;
done;
seq: invalid Zero increment value: ‘0’
Try 'seq --help' for more information.
0
1

Still, the script continues running just like in the previous example.

Let’s try using the loop counter by setting a string value:

$ COUNTER=5
$ N=10
$ while [[ $COUNTER -le $N ]]; do
    echo $COUNTER;
    COUNTER="show me an error";
done;
5
bash: [[: show me an error: syntax error in expression (error token is "me an error")

In the above example, Bash encountered an invalid expression within the loop condition, so, it stopped running the script.

6. Using Variables

Loop counters are inherently variables. We can also use variables with seq. However, we can’t use variables inside a brace expansion directly, as the content within the curly braces is parsed as string literals having no special meaning:

$ START=1
$ for VAL in {$START..5}; do
    echo $VAL;
done;
{1..5}

The above output shows that the expression wasn’t expanded to a sequence. Instead, Bash evaluated $START and used the result as a string.

Let’s check what happens with seq:

$ START=1
$ for VAL in $(seq $START 5); do
  echo $VAL;
done;
1
2
3
4
5

As seen here, seq returned the expected sequence from 1 to 5 even when using variables.

7. Handling Non-arithmetic Progression Sequences

Let’s suppose that a script requires iterating through all numbers from 0 to 20 ending in the digits 1 to 4. This case doesn’t conform to an arithmetic progression. Hence, we can’t use seq for such an iteration.

Nevertheless, we can use brace expansions in such scenarios:

$ for VAL in {0..1}{1..4}; do
    echo -n "Number=$VAL; "; # -n option to disable newlines after each echo
done;
Number=01; Number=02; Number=03; Number=04; Number=11; Number=12; Number=13; Number=14; 

To achieve a similar result via a loop counter, let’s handle the operation accordingly:

$ COUNTER=0
$ while [[ $COUNTER -le 20 ]]; do
    if [[ $COUNTER =~ .*[1-4]$ ]]; then 
        echo -n "Number=$COUNTER; ";
    fi;
    COUNTER=$(( $COUNTER + 1 ));
done;
Number=1; Number=2; Number=3; Number=4; Number=11; Number=12; Number=13; Number=14; 

Here, the output slightly differs from the previous one. The brace expansion concatenated the output of the two expressions, while Bash iterated over the values as numbers with the while loop.

8. Iterating Through Characters

We can use characters in brace expansion expressions. Thus, start and end can either both be character literals or integers, but the step has to be an integer only:

$ for VAL in {a..f..2}; do
    echo $VAL;
done;
a
c
e

As shown in the above output, the expression was expanded to return the letters from a to f with an increment of 2 between each pair of consecutive letters.

We can convert loop counters or seq values from numbers to characters using additional utilities like awk:

$ for VAL in $(seq 97 2 102); do
    echo $VAL | awk '{ printf "%c\n", $1 }';
done;
a
c
e

Here, we converted from integer to character using %c and the awk printf. However, the behavior of this format specifier differs from the shell builtin printf %c:

$ printf '%c\n' 97
9

As shown here, printf %c prints the first character of its argument. Thus, we see only 9, i.e., the first digit of the value 97.

9. Custom Formatting

seq has a built-in formatting option using printf-compatible format specifiers. This can be passed using the –format (-f) option between seq and the arguments (start, step, and end):

$ for VAL in $(seq -f '%f' 3); do
     echo $VAL;
done;
1.000000
2.000000
3.000000

When passing %f to the seq -f option, we can see the numbers are in a floating-point format.

Although brace expansions and loop counters don’t have this feature built-in, we can use commands like printf to format the variable. Here’s an example using a loop counter and printf:

$ COUNTER=1
$ while [[ $COUNTER -le 3 ]]; do
    printf '%f\n' $COUNTER;
    COUNTER=$(( $COUNTER + 1 ));
done;
1.000000
2.000000
3.000000

Nonetheless, unlike the others, brace expansions do have a built-in way to add zero-padding by adding a 0 prefix to the start number:

$ for VAL in {06..12..2}; do
    echo $VAL;
done;
06
08
10
12

Thus, the output for 6 and 8 is zero-padded. This is to match the width of larger numbers.

seq also provides an option for zero-padding via –equal-width (-w):

$ for VAL in $(seq -w 8 2 12); do
    echo $VAL;
done;
08
10
12

We got the same result as in the previous example. However, this -w option can’t be used together with the -f option.

10. Using Concatenation

For concatenating each number with some other value, we can pass a prefix or suffix to a brace expansion:

$ for VAL in file-{1..5}.log; do
     echo -n "$VAL; ";
done;
file-1.log; file-2.log; file-3.log; file-4.log; file-5.log; 

The expression was expanded as file-1.log, file-2.log, and so on.

Although seq doesn’t have the same feature, we can simulate it via the –separator (-s) option by specifying a delimiter between each pair of consecutive numbers:

$ for VAL in file-$( seq -s '.log file-' 5 ).log; do
    echo -n "$VAL; ";
done;
file-1.log; file-2.log; file-3.log; file-4.log; file-5.log; 

Alternatively, we can format the output using the –format (-f) option:

$ for VAL in $( seq -f 'file-%g.log' 5 ); do
          echo -n "$VAL; ";
  done;
file-1.log; file-2.log; file-3.log; file-4.log; file-5.log; 

Thus, we get the same result.

Finally, for loop counters, we can use the number and concatenate within the loop body:

$ COUNTER=1
$ while [[ $COUNTER -le 5 ]]; do
    echo -n "file-${COUNTER}.log; ";
    COUNTER=$(( $COUNTER + 1 ));
done;
file-1.log; file-2.log; file-3.log; file-4.log; file-5.log; 

This way, we get the same output as in the previous two examples.

11. Non-Integer Numbers

Bash supports only integer arithmetic. Thus, we can’t use floating-point numbers or other numeric representations with loop counters or brace expansions.

In fact, here’s the output of a loop passing floating-point numbers in a brace expansion:

$ for VAL in {1.2..3.6}; do
    echo $VAL;
done;
{1.2..3.6}

We observe that the expression remains unchanged.

Contrary to this behavior, seq can accept input as floating-point, hexadecimal, and octal numbers. Of course, if we don’t specify the desired format, seq displays the output in integer format by default:

$ for VAL in $( seq 0x1A 0x4 0x2C ); do
    echo -n "$VAL; ";
done;
26; 30; 34; 38; 42; 

We passed hexadecimal values and still got the result as integers.

The floating-point representation in seq has a precision up to a specific limit depending on the platform. If we work on values beyond this precision, the output may be unpredictable:

$ for VAL in $( seq 1.00000000000000000001 0.00000000000000000001 1.00000000000000000005 ); do
    echo $VAL;
done;
1.00000000000000000000
1.00000000000000000000
1.00000000000000000000
1.00000000000000000000
1.00000000000000000000 

In this case, the values aren’t in an arithmetic progression.

12. Working With Large Numbers

seq works for larger numbers. Moreover, we can even pass infinity as the end using inf:

$ seq inf
1
2
3
4
... (output truncated)

Since the command would generate numbers endlessly, let’s stop it by pressing Ctrl+C or Cmd+C.

However, we can’t use inf within our loop, as seq runs in a subshell. It’ll continue running in the subshell until interrupted. As a result, the shell may become unresponsive.

On the other hand, brace expansions won’t work well for larger numbers:

$ for VAL in {10000000000000000000..10000000000000000005}; do
    echo $VAL;
done;
{10000000000000000000..10000000000000000005}

The expression didn’t expand as a sequence. Instead, the shell assumed it to be a string value. The reason is that the given numbers are beyond integer limits in the shell.

Similarly, loop counters are also bound by the limits of integer arithmetic:

$ COUNTER=10000000000000000000
$ while [[ $COUNTER -le 10000000000000000005 ]]; do
    echo $COUNTER;
    COUNTER=$(( $COUNTER + 1 ));
done;
10000000000000000000                                                                                                                                
-8446744073709551615                                                                                                                                
-8446744073709551614                                                                                                                                
-8446744073709551613                                                                                                                                
-8446744073709551612                                                                                                                                
-8446744073709551611

Here, the values became negative integers due to integer overflow.

13. Performance Overhead

seq is a command. Hence, it consumes relatively more resources, such as creating a subshell when it’s part of the for loop. However, seq is usually faster than the brace expansion for longer ranges.

On the other hand, loop counters don’t have additional performance overhead, as they’re just shell variables having arithmetic operations performed on them.

14. Conclusion

In this article, we studied the differences between Bash loop counters, sequence iteration using brace expansion, and the seq command.

Firstly, loop counters have less overhead for a long range of values and are better suited for custom increment and decrement operations.

Secondly, brace expansions are more convenient for iterating over pre-defined evenly spaced character literals or integers up to a considerable limit.

Lastly, seq is suitable for arithmetic progressions, for instance, when using variables for the start or end values, having larger numbers or ranges, or for any numeric representation other than integers.