The shortest way to loop from 1 to 10 in bash is for i in {1..10}; do echo $i; done — and the most common bug is that brace expansion does not accept variables, so the moment you swap 10 for a variable it silently stops working.
There are four idioms for this, and they are not interchangeable. Three of them are fine in a throwaway terminal command. Only two are safe in a script that someone else will edit later. This is the difference between them, in the order you are most likely to reach for them.
Table of contents
- Brace expansion: shortest, and the one that surprises people
- seq: works with variables, but it is a separate process
- The C-style loop: the one to use in scripts
- The POSIX while loop, and looping over things instead of numbers
- Retry loops: where the loop body matters more than the loop
- How this fits the rest of the stack
- FAQ
Brace expansion: shortest, and the one that surprises people
Brace expansion is a shell feature, not a loop feature. Bash expands {1..10} into the literal text 1 2 3 4 5 6 7 8 9 10 before the for statement ever runs.
for i in {1..10}; do
echo "Run $i"
done
It supports a step, and it counts down as happily as up:
for i in {0..20..5}; do echo $i; done # 0 5 10 15 20
for i in {10..1}; do echo $i; done # counts down
for i in {01..10}; do echo $i; done # zero-padded: 01 02 ... 10
Now the trap. Because expansion happens first, before variables are substituted, this does not do what it looks like:
n=10
for i in {1..$n}; do echo $i; done
# prints the literal string: {1..$n}
No error. No warning. One iteration, with i set to that literal text. If the loop body was creating directories or sending requests, you now have a directory with a very strange name and a script that appears to have run fine. This is the most common bash-loop bug, and it usually appears when somebody refactors a hardcoded number into a variable, months after the script was written.
The step value has the same restriction. A variable step inside braces is a literal string too.
seq: works with variables, but it is a separate process
seq is an external command, so its arguments go through normal variable expansion. That makes it the obvious answer to the brace-expansion problem:
n=10
for i in $(seq 1 $n); do
echo "Run $i"
done
# with a step
for i in $(seq 0 5 20); do echo $i; done
# zero-padded, fixed width
for i in $(seq -w 1 10); do echo $i; done
The costs are real but usually small. seq forks a process, which matters if you are looping inside another loop thousands of times. It builds the entire list in memory before the loop starts, which matters at very large ranges. And it is a GNU coreutils tool — present on essentially every mainstream Linux distribution, missing or different on some BSD and minimal container images. Alpine provides it through busybox with fewer options.
If your script has to run somewhere that is not glibc Linux, this is usually the line that breaks first.
The C-style loop: the one to use in scripts
Bash’s arithmetic for handles variables, handles computed bounds, forks nothing, and reads clearly to anyone who has written C, Java, JavaScript, or Go:
n=10
for (( i = 1; i <= n; i++ )); do
echo "Run $i"
done
# step by 5
for (( i = 0; i <= 20; i += 5 )); do echo $i; done
# count down
for (( i = 10; i >= 1; i-- )); do echo $i; done
Inside the double parentheses you do not need a dollar sign on variables, integer arithmetic works normally, and the bounds can be expressions — looping to an array length needs no subshell at all.
The one caveat runs in the other direction: this form is bash and ksh, not POSIX. If your script starts with a /bin/sh shebang on a system where that is dash, it is a syntax error. Either declare bash deliberately, or use the while loop below.
For any script that will be read again, this is the right default. It is the only one of the four where changing a bound to a variable cannot silently change the meaning of the loop.
The POSIX while loop, and looping over things instead of numbers
When the script genuinely must be POSIX sh, a while loop with manual arithmetic is the portable form:
i=1
while [ "$i" -le 10 ]; do
echo "Run $i"
i=$((i + 1))
done
It is more typing and there is one more place to make a mistake — forgetting the increment gives you an infinite loop — but it runs anywhere a shell runs.
Worth saying out loud, though: a large fraction of counting loops in real scripts are counting when they should be iterating. If the numbers exist only to index into something, loop over the something:
# Counting, then indexing -- fragile
for (( i = 0; i < ${#servers[@]}; i++ )); do
ssh "${servers[$i]}" uptime
done
# Iterating -- says what it means
for host in "${servers[@]}"; do
ssh "$host" uptime
done
The quotes in the second form are load-bearing. Without them, any element containing a space splits into two iterations, and you get an SSH attempt against a hostname that does not exist.
Retry loops: where the loop body matters more than the loop
The most common real use of a 1-to-10 loop in deploy scripts is retrying something flaky — waiting for a database to accept connections, or polling a health endpoint after a release. The syntax is the easy part; the exit behaviour is where these go wrong.
#!/bin/bash
set -euo pipefail
for (( i = 1; i <= 10; i++ )); do
if curl -fsS --max-time 5 http://localhost:8080/health > /dev/null; then
echo "Healthy after $i attempt(s)"
exit 0
fi
echo "Attempt $i failed, retrying in 3s"
sleep 3
done
echo "Health check never passed after 10 attempts" >&2
exit 1
Two details do the work. The explicit failure after the loop means a never-healthy service fails the deploy instead of quietly continuing — a loop that falls through without an error is how a broken release gets marked successful. And the request timeout means a hanging endpoint costs five seconds rather than blocking the whole pipeline; a retry loop wrapped around a request with no timeout is not a retry loop, it is a hang with extra steps.
If you find yourself writing this, notice what it is: a health check, implemented in bash, running on a machine you have to log into to read the output of.
How this fits the rest of the stack
Retry-until-healthy loops, restart-if-dead loops, and wait-for-the-database loops are all the same admission — the platform underneath is not watching the process, so a shell script has to. That works until the script is the thing that fails, at which point nobody finds out for a while.
RunxBuild takes that job: a service deploys from your repo, the build log and the runtime logs sit in the same place, autoscaling moves between plan floors and ceilings you pick, and a bad release rolls back to the previous deploy. The shell loop stops being infrastructure and goes back to being a script. To see what a service, a managed Postgres or MySQL and the storage beside them actually add up to, the RunxBuild hosting calculator lists them as separate line items.
Useful related references:
- Looping in Bash: for, while, until, and the Loop That Eats Your Filenames
- Bash For Loop Over a Range: Brace Expansion, seq, and the Variable Trap
- Bash Append to File:
>>,tee -a, and Heredoc - Services on RunxBuild
FAQ
Why does a variable inside {1..$n} not work in bash?
Brace expansion runs before variable expansion, so the shell sees the braces as literal text and never substitutes the variable. The loop runs exactly once with the loop variable set to that literal string, and no error is printed. Use a C-style loop or $(seq 1 $n) whenever a bound comes from a variable.
Which is faster, brace expansion or seq?
Brace expansion is faster because it is built into the shell and forks nothing, while seq starts a process and builds the whole list in memory first. The difference is irrelevant at ten iterations and matters at millions. Choose on correctness — variables and computed bounds — rather than on speed.
How do I loop from 1 to 10 with zero padding?
Brace expansion pads if you pad the first number: {01..10} gives 01 through 10. With seq, use seq -w 1 10, which pads every value to the width of the largest. With a C-style loop, format at print time using printf with a %02d conversion.
Is the C-style for loop portable?
It is bash and ksh, not POSIX. If your script’s shebang points at /bin/sh and that resolves to dash — the default on Debian and Ubuntu — it is a syntax error. Either use a bash shebang deliberately, or fall back to a while loop with a manual increment.
How do I count down from 10 to 1?
Brace expansion reverses on its own with {10..1}. A C-style loop does it by decrementing. With seq, pass a negative step: seq 10 -1 1. The C-style form is the safest of the three if either bound might become a variable later.