Short answer
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
# 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"; doneEvery 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
$ for name in alice bob carol; do echo "hello $name"; done
hello alice
hello bob
hello carolBash 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:
#!/bin/bash
for name in alice bob carol
do
echo "hello $name"
doneLeave 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:
$ 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
$ 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}
$ 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:
$ 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
5seq (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.
$ 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.0Big 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:
| Loop | Peak 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
$ 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=7The 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
$ 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
$ for f in *.log; do echo "[$f]"; done
[*.log]
$ shopt -s nullglob
$ for f in *.log; do echo "[$f]"; done; echo "loop done"
loop doneA 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.exampleonly showed up aftershopt -s dotglob. - Subfolders:
shopt -s globstar(bash 4.0 and newer) makes**/*.txtmatch in the current folder and in every folder below it. - Very large or deep trees: use
findwith-print0and awhileloop that reads up to the NUL byte, which survives any file name.
Both, run in the same folder after adding two files in subfolders:
$ 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):
$ 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:
$ 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].-rkeeps 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 filledline, and the loop never got to use it.< hosts.txtgoes afterdone, 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
$ 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
$ 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=4Every 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
$ 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 cAny 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
$ 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: cacheOnly "${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:
$ declare -A port=([ssh]=22 [http]=80 [https]=443)
$ for svc in "${!port[@]}"; do echo "$svc -> ${port[$svc]}"; done
ssh -> 22
https -> 443
http -> 80We 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
$ 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
7Even 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.
$ 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
afterRun 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:
$ 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
$ 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
$ 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 DOWNOne 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
$ 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.gzEach 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
for h in web1 web2 db1; do
echo "== $h"
ssh -n -o ConnectTimeout=5 "$h" 'uptime; df -h /'
doneThe 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
| for | while | until | |
|---|---|---|---|
| Runs the body | once per item of a list, or while a (( )) test holds | as long as the command succeeds | as long as the command fails |
| Made for a known list of items | Yes | No | No |
| Read a file line by line | No | Yes | No |
| Range held in a variable | C-style or $(seq) | Yes | Yes |
| Endless loop | for ((;;)) | while true | until false |
| Typical use | files, arrays, fixed ranges | lines of a file, polling | wait until something is ready |
| In POSIX sh (dash) | list form only | Yes | Yes |
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:
$ n=3; until (( n == 0 )); do echo "n=$n"; n=$((n-1)); done
n=3
n=2
n=1Errors you will hit with for loops
Symptom, cause, fix
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.
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.shchecks the syntax without running it.
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/bashand run it withbash script.shor./script.sh.
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").
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 nullglobbefore the loop, or[ -e "$f" ] || continueinside it.
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 < fileordone < <(command)instead ofcommand | while.
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 runsed -i 's/\r$//' fileonce.
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 usessh -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.