Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Bash For Loop 1 to 10: Four Ways, and When Each One Breaks

Sean

Platform Writer

Aug 20, 2026
8 min read

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.

Bash For Loop 1 to 10: Four Ways, and When Each One Breaks

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

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:

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.

#bash for loop#bash#shell scripting#linux#seq