Guides

Bash for loop: lists, ranges, files, lines and arrays, with real output

Bash for loop syntax with tested output: lists, {1..10} ranges and steps, C-style loops, files, lines with while read, arrays, break, continue, parallel.

Published ~12 min read
On this page

Short answer

A bash for loop runs the commands between do and done once for each item, with the loop variable set to that item. There are three forms you will actually use:
  • A list: for name in alice bob carol; do echo "$name"; done
  • A range: for i in {1..10}; do echo "$i"; done
  • C-style, for ranges held in variables: for ((i = 1; i <= n; i++)); do echo "$i"; done
bash
# 1. over a list of words, files or array elements
for name in alice bob carol; do echo "hello $name"; done

# 2. over a fixed number range (no variables inside the braces)
for i in {1..10}; do echo "$i"; done

# 3. C-style: counts with variables, steps and conditions
for ((i = 1; i <= n; i++)); do echo "$i"; done

Every snippet below that shows output was run in bash 5.2.15 on Debian 12, and the /bin/sh cases in dash 0.5.12; the output is copied from that run, including the broken cases, so you can see exactly what goes wrong and why. Version notes come from the bash NEWS file and the bash manual page.

Bash versions and loops

Tested in
bash 5.2.15Debian 12, 2 Nov 2026
Range {x..y}
bash 3.0+bash NEWS
Zero padding {01..10}
bash 4.0+bash NEWS
Step {x..y..incr}
bash 4.0+bash 4.0 manual; in 3.0 the step was always 1
C-style for ((...))
bash 2.04+bash NEWS
mapfile, globstar
bash 4.0+bash NEWS
shopt -s lastpipe
bash 4.2+bash NEWS
/bin/sh on Debian and Ubuntu
dashno ranges, no C-style loop

Loop over a list of words

bash
$ for name in alice bob carol; do echo "hello $name"; done
hello alice
hello bob
hello carol

Bash expands everything after in into a list of words, sets name to each one in turn and runs the body. In a script you usually spread the same loop over several lines; the semicolons are only needed where a keyword shares a line with the list:

loop.sh
#!/bin/bash
for name in alice bob carol
do
    echo "hello $name"
done

Leave out in and the loop runs over the script’s arguments, exactly like for a in "$@", so an argument with a space stays one item:

bash
$ set -- "first arg" second
$ for a; do echo "[$a]"; done
[first arg]
[second]

Two details from the bash manual are worth knowing for scripts. The exit status of the whole loop is the status of the last command that ran inside it, and a list that expands to nothing runs no commands and returns 0. A loop is not an error check: if you need to know that a step failed, test it inside the body.

Loop over a number range: 1 to 10, steps and padding

bash
$ for i in {1..5}; do echo "$i"; done
1
2
3
4
5
$ echo {1..10..2}          # step of 2
1 3 5 7 9
$ echo {10..1..3}          # counting down
10 7 4 1
$ echo {01..10}            # zero padded, same width
01 02 03 04 05 06 07 08 09 10
$ echo {a..e}              # letters work too
a b c d e

{x..y} is brace expansion, not a loop feature: bash turns it into a list of words before the loop starts, which is why echo shows what the loop will receive. The optional third part is the step, and a leading zero on either end pads every number to the same width, handy for file names like backup-01.tar.

Two range traps: 1..10 without braces, and {1..$n}

bash
$ for i in 1..10; do echo "$i"; done
1..10
$ n=5; for i in {1..$n}; do echo "$i"; done
{1..5}

Without braces, 1..10 is just a word, so the loop runs once. The second case surprises everyone once: the bash manual states that brace expansion is performed before any other expansion, so at that moment $n is still the text $n, the braces do not form a valid range, and they are left alone. Only then is $n replaced, leaving the single word {1..5}. When the end of the range lives in a variable, use a C-style loop or seq:

bash
$ n=5; for ((i = 1; i <= n; i++)); do echo "$i"; done
1
2
3
4
5
$ n=5; for i in $(seq 1 "$n"); do echo "$i"; done
1
2
3
4
5

seq (GNU coreutils) also covers what brace expansion cannot: a step with a variable, fixed width with -w, and decimals, which bash arithmetic does not do at all because it works only with integers.

bash
$ seq 0 2 10 | tr '\n' ' '; echo
0 2 4 6 8 10
$ seq -w 8 11 | tr '\n' ' '; echo
08 09 10 11
$ seq 1 0.5 3 | tr '\n' ' '; echo
1.0 1.5 2.0 2.5 3.0

Big ranges: braces build the whole list in memory first

Because {1..1000000} and $(seq 1 1000000) are expanded before the first pass, the shell holds a million words at once. A C-style loop only keeps a counter. We measured the peak memory of the shell (VmHWM in /proc/PID/status) after an empty loop of a million steps:

LoopPeak memory (VmHWM)
for i in {1..1000000}284,352 kB
for i in $(seq 1 1000000)103,372 kB
for ((i = 1; i <= 1000000; i++))3,004 kB

For a few hundred items it makes no difference. For counts in the hundreds of thousands on a small server, write the C-style loop.

C-style for loop

bash
$ for ((i = 0; i < 3; i++)); do echo "i=$i"; done
i=0
i=1
i=2
$ for ((i = 1, j = 10; i < j; i += 3, j -= 3)); do echo "i=$i j=$j"; done
i=1 j=10
i=4 j=7

The three parts are arithmetic: the first runs once, the second is checked before every pass and the loop stops when it becomes 0 (false), and the third runs after every pass. Inside the double parentheses you can drop the $ in front of variables, and a comma puts two expressions in one part, as the second example does with two counters. Counting down is for ((i = 10; i > 0; i--)).

The bash manual says an omitted expression counts as 1, so for ((;;)); do ...; done runs until something inside it calls break or you press Ctrl+C. Put a sleep inside such a loop on a server, or it will keep one CPU core busy.

Loop over files: globs, spaces and empty folders

bash
$ touch "annual report.txt" notes.txt todo.txt
$ for f in *.txt; do echo "[$f]"; done
[annual report.txt]
[notes.txt]
[todo.txt]
$ for f in $(ls *.txt); do echo "[$f]"; done      # wrong
[annual]
[report.txt]
[notes.txt]
[todo.txt]

Let the glob produce the list. Every match from *.txt is one item, spaces and all, as long as you write "$f" in quotes inside the body. Looping over $(ls) hands the output of ls to word splitting, and a file name with a space becomes two names that do not exist.

When nothing matches: nullglob

bash
$ for f in *.log; do echo "[$f]"; done
[*.log]
$ shopt -s nullglob
$ for f in *.log; do echo "[$f]"; done; echo "loop done"
loop done

A pattern that matches nothing stays as the literal text, so the body runs once with *.log as the “file name”, and a gzip or rm inside it then complains about a file that does not exist. shopt -s nullglob makes an empty match expand to nothing, so the loop simply does not run. The alternative without a shell option is a guard as the first line of the body: [ -e "$f" ] || continue.

  • Hidden files: * skips names that start with a dot. In our run, .env.example only showed up after shopt -s dotglob.
  • Subfolders: shopt -s globstar (bash 4.0 and newer) makes **/*.txt match in the current folder and in every folder below it.
  • Very large or deep trees: use find with -print0 and a while loop that reads up to the NUL byte, which survives any file name.

Both, run in the same folder after adding two files in subfolders:

bash
$ mkdir -p sub/deeper && touch sub/a.txt sub/deeper/b.txt
$ shopt -s globstar
$ for f in **/*.txt; do echo "[$f]"; done
[annual report.txt]
[notes.txt]
[sub/a.txt]
[sub/deeper/b.txt]
[todo.txt]
$ find . -name "*.txt" -print0 | while IFS= read -r -d "" f; do echo "[$f]"; done
[./sub/a.txt]
[./sub/deeper/b.txt]
[./todo.txt]
[./notes.txt]
[./annual report.txt]

The glob returns the names sorted, find returns them in the order the file system hands them out, so pipe through sort -z if the order matters. The find command guide covers the tests you can put in front of -print0, such as -mtime and -size.

Loop over the lines of a file: while read, not for

This is the case that breaks most often. Our test file has a line with two words, an indented line, a line that is only *, and a last line without a newline at the end (common in files saved by Windows editors or written with printf):

bash
$ for line in $(cat hosts.txt); do echo "[$line]"; done      # wrong
[web-1]
[10.0.0.5]
[indented]
[line]
[files]
[hosts.txt]
[last]
[line]
[without]
[newline]

$(cat file) is split at every space, tab and newline, so the loop sees words, not lines. Worse, each word is then expanded as a glob: the line * turned into the names of the files in the current folder (files and hosts.txt). The correct loop reads one line per pass:

bash
$ while IFS= read -r line || [[ -n $line ]]; do echo "[$line]"; done < hosts.txt
[web-1 10.0.0.5]
[  indented line]
[*]
[last line without newline]
  • IFS= keeps leading and trailing spaces. Without it our indented line came back as [indented line].
  • -r keeps backslashes as they are; without it, read treats a backslash as an escape character (bash manual).
  • || [[ -n $line ]] handles a last line without a newline. Without it, our run printed only three lines: read fails on the last one but has already filled line, and the loop never got to use it.
  • < hosts.txt goes after done, so the whole loop reads from the file.

To split each line into fields, name several variables: with while IFS=' ' read -r host ip, the first line gave host=web-1 ip=10.0.0.5. For a file you want in memory at once, mapfile -t lines < hosts.txt fills an array with one line per element (our file gave 4 elements, the third one *, unexpanded).

Files from Windows: a hidden \r at the end of every line

bash
$ printf 'web1\r\nweb2\r\n' > hosts-win.txt
$ while IFS= read -r h; do echo "[$h]"; done < hosts-win.txt | cat -A
[web1^M]$
[web2^M]$
$ while IFS= read -r h; do ssh -n "$h" true; done < hosts-win.txt
hostname contains invalid characters
hostname contains invalid characters
$ while IFS= read -r h; do h=${h%$'\r'}; echo "[$h]"; done < hosts-win.txt | cat -A
[web1]$
[web2]$

Files saved with Windows line endings end every line with a carriage return plus a newline (CRLF). read stops at the newline and keeps the carriage return, so every value ends in an invisible \r, which cat -A shows as ^M. In a terminal the value looks right, but ssh (OpenSSH 9.2 in our run) rejects every host name. h=${h%$'\r'} as the first line of the body cuts it off; to clean the file itself, sed -i 's/\r$//' hosts-win.txt gave the same output in our run, and dos2unix does the same job where it is installed.

Why the counter is 0 after the loop

bash
$ count=0; cat hosts.txt | while IFS= read -r line; do count=$((count+1)); done; echo "count=$count"
count=0
$ count=0; while IFS= read -r line || [[ -n $line ]]; do count=$((count+1)); done < hosts.txt; echo "count=$count"
count=4

Every part of a pipeline runs in its own subshell, so the loop after the | counted in a copy of count that disappeared with it. Feed the loop with < file or < <(command) instead of a pipe. In a script you can also set shopt -s lastpipe (bash 4.2 and newer), which runs the last part of the pipe in the current shell; our test gave count=2 for two lines with it. It has no effect in an interactive terminal, where job control is on.

Why the loop stops after the first line

bash
$ while IFS= read -r h; do echo "host $h"; cat > /dev/null; done < three.txt
host a
$ while IFS= read -r h; do echo "host $h"; cat > /dev/null < /dev/null; done < three.txt
host a
host b
host c

Any command in the body that reads standard input shares it with read. Here cat swallowed the rest of the file in the first pass. In real scripts the culprit is usually ssh: a loop over a host list that runs ssh "$h" uptime only reaches the first server. ssh -n (redirects stdin from /dev/null, according to its man page) or < /dev/null on that command fixes it.

Loop over an array

bash
$ servers=("web 1" "db 2" "cache")
$ for s in "${servers[@]}"; do echo "[$s]"; done
[web 1]
[db 2]
[cache]
$ for s in ${servers[@]}; do echo "[$s]"; done          # no quotes: split again
[web]
[1]
[db]
[2]
[cache]
$ for s in "${servers[*]}"; do echo "[$s]"; done        # [*]: one single word
[web 1 db 2 cache]
$ for i in "${!servers[@]}"; do echo "$i: ${servers[$i]}"; done
0: web 1
1: db 2
2: cache

Only "${servers[@]}", with the quotes and the @, gives you each element exactly once. ${!servers[@]} loops over the indexes when you need the position as well. Associative arrays (declare -A) work the same way, but do not rely on the order:

bash
$ declare -A port=([ssh]=22 [http]=80 [https]=443)
$ for svc in "${!port[@]}"; do echo "$svc -> ${port[$svc]}"; done
ssh -> 22
https -> 443
http -> 80

We declared ssh, http, https and got ssh, https, http. If the order matters, keep a normal array of the keys and loop over that.

break and continue

bash
$ for i in {1..10}; do if (( i % 2 == 0 )); then continue; fi; if (( i > 7 )); then break; fi; echo "$i"; done
1
3
5
7

Even numbers are skipped by continue, and break ends the loop once i passes 7, so 9 never prints. continue jumps to the next item, break leaves the loop. Both take a number for nested loops: continue 2 and break 2 act on the loop one level further out.

bash
$ for a in 1 2 3; do for b in x y z; do if [[ $b == y ]]; then continue 2; fi; echo "$a$b"; done; done
1x
2x
3x
$ for a in 1 2 3; do for b in x y z; do if [[ $a == 2 ]]; then break 2; fi; echo "$a$b"; done; done; echo "after"
1x
1y
1z
after

Run loop iterations in parallel

A for loop runs one pass after another. For slow, independent jobs (downloads, backups of several folders, checks against many hosts) you can start them side by side. We timed four jobs of sleep 1 each:

bash
$ time (for i in 1 2 3 4; do sleep 1; done)

real    0m4.003s
user    0m0.002s
sys     0m0.001s
$ time (for i in 1 2 3 4; do sleep 1 & done; wait)

real    0m1.001s
user    0m0.003s
sys     0m0.000s
$ time (printf '%s\n' 1 2 3 4 | xargs -P 4 -I{} sh -c 'sleep 1; echo "job $1 done"' _ {})
job 1 done
job 2 done
job 3 done
job 4 done

real    0m1.002s
user    0m0.004s
sys     0m0.000s
$ time (printf '%s\n' 1 2 3 4 | xargs -P 2 -I{} sh -c 'sleep 1; echo "job $1 done"' _ {})
job 1 done
job 2 done
job 3 done
job 4 done

real    0m2.003s
user    0m0.004s
sys     0m0.001s

& plus wait starts every pass at once, which is fine for four jobs and a bad idea for four hundred. xargs -P N keeps at most N jobs running, as the second timing shows (two at a time, two rounds). The order of the output is not guaranteed with either method. The item reaches the inner shell as $1 (the _ fills $0) and never as part of the script text: with sh -c '... {} ...', a file name such as $(reboot) would be run as code, and in our test the name a$(echo INJECTED) printed aINJECTED. For file names, combine it with find -print0 and xargs -0; the xargs guide has the details on -I, -n and -0.

for loop one-liners for server admin

Change the extension of many files

bash
$ for f in *.txt; do mv -- "$f" "${f%.txt}.md"; done; ls
annual report.md
notes.md
sub
todo.md

${f%.txt} cuts .txt from the end of the name, and -- stops mv from reading a name that starts with a dash as an option. More rename patterns (lower case, spaces to underscores, prefixes) are in the mv command guide.

Check which hosts answer

bash
$ for h in 127.0.0.1 192.0.2.1; do if ping -c 1 -W 1 "$h" > /dev/null 2>&1; then echo "$h up"; else echo "$h DOWN"; fi; done
127.0.0.1 up
192.0.2.1 DOWN

One packet per host with a one-second wait keeps the loop quick; 192.0.2.1 comes from a range reserved for documentation (RFC 5737), so nothing answers there. Replace the list with your own servers, or read it from a file with the while read loop above. The ping command guide explains -c and -W.

Compress and empty log files

bash
$ for f in *.log; do gzip -c -- "$f" > "$f.$(date +%F).gz" && : > "$f"; done; ls
app.log
app.log.2026-11-02.gz
worker.log
worker.log.2026-11-02.gz

Each log is packed into a dated copy and only then emptied (: > truncates the file in place instead of deleting it). Lines written between the two steps are lost, so for logs that matter, logrotate is the right tool; the loop is for a quick clean-up. To keep a loop’s own output in a log, see redirecting stdout and stderr, and to run it every night, the crontab guide (cron uses /bin/sh, so put bash loops in a #!/bin/bash script and call that).

Run one command on several servers

bash
for h in web1 web2 db1; do
    echo "== $h"
    ssh -n -o ConnectTimeout=5 "$h" 'uptime; df -h /'
done

The host names are aliases from ~/.ssh/config (the SSH config file guide shows how to set them up). -n is not strictly needed in a for loop, but it keeps the loop working when you later switch to reading the hosts from a file with while read.

for vs while vs until

forwhileuntil
Runs the bodyonce per item of a list, or while a (( )) test holdsas long as the command succeedsas long as the command fails
Made for a known list of itemsYesNoNo
Read a file line by lineNoYesNo
Range held in a variableC-style or $(seq)YesYes
Endless loopfor ((;;))while trueuntil false
Typical usefiles, arrays, fixed rangeslines of a file, pollingwait until something is ready
In POSIX sh (dash)list form onlyYesYes
Behavior from the bash 5.2 manual page; the sh row checked in dash 0.5.12 on Debian 12, 2 Nov 2026.

Pick for when you have the items, while when you read them or wait for a condition, and until when “wait until this works” reads more naturally than a negated while:

bash
$ n=3; until (( n == 0 )); do echo "n=$n"; n=$((n-1)); done
n=3
n=2
n=1

Errors you will hit with for loops

Symptom, cause, fix

  1. Symptom: syntax error near unexpected token `echo'

    Cause
    The do is missing: for i in 1 2 3; echo $i; done.
    Fix
    Write for i in 1 2 3; do echo "$i"; done.
  2. Symptom: syntax error: unexpected end of file

    Cause
    The loop has no done, often after a copy and paste that lost the last line. Since bash 5.3 the message adds from `for' command on line N, the line where the loop starts.
    Fix
    Close every loop with done; bash -n script.sh checks the syntax without running it.
  3. Symptom: Syntax error: Bad for loop variable

    Cause
    A C-style loop runs under sh (dash), for example via sh script.sh or a cron line.
    Fix
    Start the script with #!/bin/bash and run it with bash script.sh or ./script.sh.
  4. Symptom: The loop runs once and prints {1..5} or 1..10

    Cause
    A variable inside the braces, missing braces, or the script runs under sh.
    Fix
    Use for ((i = 1; i <= n; i++)) or $(seq 1 "$n").
  5. Symptom: The loop works on a file called *.log

    Cause
    No file matched the pattern, so the pattern itself became the item.
    Fix
    Add shopt -s nullglob before the loop, or [ -e "$f" ] || continue inside it.
  6. Symptom: A variable set in the loop is empty or 0 afterwards

    Cause
    The loop sits behind a pipe and ran in a subshell.
    Fix
    Use done < file or done < <(command) instead of command | while.
  7. Symptom: ssh: hostname contains invalid characters, for names read from a file

    Cause
    The list was saved with Windows line endings, so every value ends in \r.
    Fix
    Add h=${h%$'\r'} in the body, or run sed -i 's/\r$//' file once.
  8. Symptom: A while read loop handles only the first line

    Cause
    A command in the body (often ssh) reads the rest of standard input.
    Fix
    Give that command < /dev/null, or use ssh -n.

Not sure how a script is being started, or whether it runs under bash at all? See how to run a .sh file in Linux for the difference between ./script.sh, bash script.sh and sh script.sh.

Loops like these are where a server starts saving you time: renaming a release folder, checking a list of hosts, packing logs before the disk fills. Our Standard KVM servers give you full root access, so you can run, time and schedule loops like these on your own machine. For the commands that usually go inside the loop body, keep the Linux commands cheat sheet open.

How we tested

OS image
Debian 12
Versions
bash 5.2.15dash 0.5.12GNU coreutils 9.1 (seq)GNU findutils 4.9.0 (xargs)OpenSSH 9.2p1
Tested on
Method
Every snippet with output on this page was run in a scratch folder with bash -c and the output copied from the transcript, including the error messages. Memory is the peak resident size (VmHWM) of the shell after an empty loop of 1,000,000 steps; times are from the bash time keyword.

Frequently asked

How do I write a for loop in bash on one line?

Separate the parts with semicolons: 'for i in 1 2 3; do echo "$i"; done'. The semicolon before do and before done is required on one line; leaving out do gives 'syntax error near unexpected token', and leaving out done gives 'syntax error: unexpected end of file'.

How do I loop from 1 to 10 in bash?

Use brace expansion: 'for i in {1..10}; do echo "$i"; done'. Add a step with {1..10..2} and zero padding with {01..10}. Writing 'for i in 1..10' without braces runs the loop once with the text 1..10.

Why does for i in {1..$n} not work in bash?

Bash performs brace expansion before it expands variables, so {1..$n} is not a valid range at that moment and stays as text; the loop then runs once with a word like {1..5}. Use a C-style loop, 'for ((i = 1; i <= n; i++))', or 'for i in $(seq 1 "$n")'.

How do I loop through the lines of a file in bash?

Use while read, not for: 'while IFS= read -r line || [[ -n $line ]]; do echo "$line"; done < file.txt'. A for loop over $(cat file.txt) splits lines at spaces and expands * into file names. IFS= keeps leading spaces, -r keeps backslashes, and the || test handles a last line without a newline.

What is the difference between a for loop and a while loop in bash?

A for loop runs once per item of a list it already has (words, files, array elements, a range) or as a C-style counter. A while loop runs as long as a command succeeds, which makes it the right choice for reading a file line by line or waiting for a condition. until is the opposite of while: it runs as long as the command fails.

Related articles

All guides

Linux commands cheat sheet: files, processes, networking and more

The Linux commands a server admin uses daily (files, permissions, processes, networking, systemd, packages), grouped into one reference, linking the full guide for each.

How to redirect stdout and stderr to a file in Bash

Redirect stdout and stderr to a file in Bash: > and >>, 2>, why 2>&1 goes last, &>, /dev/null, tee -a, and the versions for cron and systemd services.

crontab -e, -l and -r explained: editing, listing and clearing a user's cron jobs

crontab -e edits your scheduled jobs, -l lists them, -r deletes all of them with no confirmation. The five time fields, cron's own environment and the %-escaping trap.

How to run a .sh file in Linux: chmod +x, ./script.sh, bash, sh and source

Run a .sh file in Linux: chmod +x, then ./script.sh, or bash script.sh without chmod. What sh and source change, and fixes for Permission denied and CRLF.

xargs command in Linux: -0, -n, -I {}, -P and when to use find -exec instead

xargs turns input into arguments for another command: -0 with find -print0, -n per run, -I {} placeholder, -P in parallel, -r for empty input.

find command in Linux: search by name, size, age and permissions, with -exec and -delete

The find command in Linux by example: -name, -type, -size, -mtime, -perm, -maxdepth, -exec vs -exec +, safe -delete, -print0 with xargs and -prune.

How to rename a directory in Linux: the mv command for files and folders

How to rename a directory in Linux with mv: rename files and folders, move them into directories, -i, -n and -T, bulk renames, and fixes for mv errors.