Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Bash loops: for, while and until by example

By updated

Improve this page

A Bash loop repeats an action over each item of a set. With for, you iterate over a list known in advance: servers to ping, .log files to compress, the values of an array. With while, you process a stream whose size you do not know: the lines of a CSV, the output of a command. With until, you wait for a condition to become true, typically a service that is starting.

This page goes straight to administration examples actually run, then to the traps that make scripts fail in production: for f in $(ls), the glob that matches nothing, the counter swallowed by a subshell.

What you will learn

  • Write a for loop over a list, an array, a file glob and a numeric range
  • Write a C-style for loop with for (( i=0; i<10; i++ ))
  • Use while IFS= read -r to read a file or a CSV line by line
  • Wait for a service with until
  • Control the flow with break, continue and break 2
  • Recognise the traps that break a loop, and know when not to write a loop at all

When is it useful?

A loop becomes useful as soon as an action must repeat over several items. In Linux administration, the cases always come back to the same families:

  • check the reachability of a list of servers
  • compress or rotate the .log files of a directory
  • read a CSV inventory and act only on the production machines
  • loop over the output of a command such as systemctl or find
  • wait for a port to open before continuing a deployment
  • retry a network operation several times before giving up

The RHCSA objective (EX200, RHEL 10) asks for it in so many words: "Use looping constructs (for, etc.) to process file, command line input". The published LFCS curriculum, for its part, has no domain dedicated to scripting.

The for loop: iterate over a known list

for is the most used loop in administration. The principle is simple: you give it a list of items, it runs the loop body once per item, placing the current item in a variable. The syntax almost reads like a sentence: "for each host in this list, do this".

A literal list

The most direct case: you write the list by hand. Here, a reachability check on two addresses, with ping -c1 -W1 (a single packet, one second of wait at most) so as not to block the script.

Terminal window
for host in 127.0.0.1 10.255.255.1; do
if ping -c1 -W1 "$host" >/dev/null 2>&1; then
echo "$host: reachable"
else
echo "$host: UNREACHABLE"
fi
done

Actual output:

127.0.0.1: reachable
10.255.255.1: UNREACHABLE

Note the double quotes around "$host". They are systematic as soon as a variable is passed to a command: without them, a value containing a space would be split into several arguments.

An array, with a counter

As soon as the list exceeds three items, an array is more readable and easier to change. "${servers[@]}" expands the array into as many items as it contains, and ${#servers[@]} gives the number of items. We take the opportunity to keep a counter and print a summary.

Terminal window
servers=("127.0.0.1" "10.255.255.1" "127.0.0.2")
unreachable=0
for host in "${servers[@]}"; do
if ping -c1 -W1 "$host" >/dev/null 2>&1; then
echo " $host: reachable"
else
echo " $host: UNREACHABLE"
unreachable=$((unreachable + 1))
fi
done
echo "Summary: $unreachable/${#servers[@]} server(s) unreachable"

Actual output:

127.0.0.1: reachable
10.255.255.1: UNREACHABLE
127.0.0.2: reachable
Summary: 1/3 server(s) unreachable

The files of a directory, with a glob

To iterate over files, the right form is the glob (*.log), not a command. Bash expands the pattern into a list of file names, without splitting on spaces, which settles most problems with exotic names from the start.

The example compresses every .log of a directory and leaves the other files alone. The shopt -s nullglob is explained further down: it avoids the unpleasant case where no file matches.

Terminal window
shopt -s nullglob
compressed=0
for f in *.log; do
gzip "$f"
echo " compressed: $f -> $f.gz"
compressed=$((compressed + 1))
done
echo "$compressed file(s) compressed"

In a directory containing access.log, error.log, debug.log and keep.txt:

compressed: access.log -> access.log.gz
compressed: debug.log -> debug.log.gz
compressed: error.log -> error.log.gz
3 file(s) compressed

keep.txt was not touched: the *.log pattern filtered what was needed, without an extra if, and the loop body stays short.

A numeric range

Bash can expand a range with braces. It is the most readable form for a known number of iterations, and it accepts a step.

Terminal window
for i in {1..5}; do
printf "%d " "$i"
done
echo
# 1 2 3 4 5
for i in {0..20..5}; do # step of 5
printf "%d " "$i"
done
echo
# 0 5 10 15 20

Watch out, this syntax has an important limit: it does not work with a variable. {1..$n} does not expand, the trap is detailed in its own section.

The C-style for loop

When you need an index, a custom increment or a computation in the condition, the arithmetic form for (( init; condition; increment )) is better suited. It reads like C or Java: initialise, test, increment.

Here is a retry loop with increasing wait, a classic of deployment scripts that must tolerate a slow service.

Terminal window
max=4
for (( attempt=1; attempt<=max; attempt++ )); do
wait=$(( attempt * 2 ))
echo "Attempt $attempt/$max on 10.255.255.1..."
if ping -c1 -W1 10.255.255.1 >/dev/null 2>&1; then
echo " server reachable"
break
fi
if (( attempt < max )); then
echo " failed, retrying in ${wait}s"
sleep "$wait"
else
echo " final failure after $max attempts"
fi
done

Actual output (the server never answers, the four attempts are used up):

Attempt 1/4 on 10.255.255.1...
failed, retrying in 2s
Attempt 2/4 on 10.255.255.1...
failed, retrying in 4s
Attempt 3/4 on 10.255.255.1...
failed, retrying in 6s
Attempt 4/4 on 10.255.255.1...
final failure after 4 attempts

Inside the double parentheses, variables are written without $ (attempt<=max and not $attempt<=$max) and the arithmetic is native. It is also the form to use to walk an array by index, when you need the item's number and not only its value:

Terminal window
disks=("/dev/sda1" "/dev/sdb1" "/dev/sdc1")
for (( i=0; i<${#disks[@]}; i++ )); do
printf " disk %d/%d: %s\n" "$((i+1))" "${#disks[@]}" "${disks[i]}"
done
disk 1/3: /dev/sda1
disk 2/3: /dev/sdb1
disk 3/3: /dev/sdc1

The while loop: process a stream of unknown size

while repeats as long as a condition stays true. In administration, its most frequent condition is not a counter but a successful read: "as long as I manage to read a line, process it". It is the tool for text, files and command output.

Read a file line by line

Here is the canonical form, to memorise as is. Each of its three elements fixes a specific problem, demonstrated further down in the traps.

Terminal window
while IFS= read -r line; do
printf "[%s]\n" "$line"
done < sshd-extract.conf

Breakdown:

  • IFS= empties the field separator for the duration of read, which preserves the spaces and tabs at the start and end of the line
  • read -r prevents the interpretation of backslashes (\t stays \t, it does not become a tab)
  • < file redirects the file to the loop's input, without creating a subshell

On an indented configuration extract, the output shows that the original indentation is intact:

[ ListenAddress 10.0.0.5 ]
[ PermitRootLogin no]

Read a CSV field by field

read can also split a line into fields. You just give it the separator through IFS and several variables. Here, a server inventory from which only production is kept.

The first read -r _header consumes the header line before the loop. The braces group the two reads so that they share the same redirection.

Terminal window
{
read -r _header
while IFS=, read -r name ip role env; do
[ "$env" = "prod" ] || continue
printf "%-8s %-12s %s\n" "$name" "$ip" "$role"
done
} < fleet.csv

With a fleet.csv containing four machines, one of them a staging one:

web01 10.0.0.11 frontend
web02 10.0.0.12 frontend
db01 10.0.0.21 database

The test01 machine was indeed filtered out by the continue. Note the difference with the previous form: here we want to split, hence IFS=,. When we want the raw line, we set IFS= empty.

Loop over the output of a command

To iterate over what a command produces, the right construct is done < <(command), called process substitution. It plugs the command's output into the loop's input without going through a pipe, which avoids the subshell trap detailed further down.

The example walks the active services and queries their state. Here we want to split the unit name from the rest of the line, so we leave IFS at its default and store the leftover in _rest.

Terminal window
while read -r unit _rest; do
state=$(systemctl is-active "$unit")
printf " %-24s %s\n" "$unit" "$state"
done < <(systemctl list-units --type=service --state=running --no-legend --no-pager)

Actual output (extract):

atd.service active
caddy.service active
containerd.service active
cron.service active

A simple counter

while also accepts a classic arithmetic condition, with double parentheses. It is the form to choose when the number of iterations depends on a variable changed inside the loop.

Terminal window
counter=1
while (( counter <= 3 )); do
echo "Iteration $counter"
counter=$((counter + 1))
done
Iteration 1
Iteration 2
Iteration 3

until: wait for a condition to become true

until is the exact opposite of while: the loop runs as long as the condition is false, and stops as soon as it becomes true. Its emblematic use in administration is waiting for a service: you repeat the test until the port answers.

The example waits for a TCP port to open. The nc -z test tries a connection without sending data; as long as it fails, we wait.

Terminal window
attempts=0
until nc -z 127.0.0.1 8099 2>/dev/null; do
attempts=$((attempts + 1))
echo " service not ready yet, attempt $attempts..."
sleep 1
done
echo "Port 8099 open after $attempts attempt(s)."

Starting a service that takes three seconds to start, the actual output is:

service not ready yet, attempt 1...
service not ready yet, attempt 2...
service not ready yet, attempt 3...
Port 8099 open after 3 attempt(s).

As is, this loop waits forever if the service never starts. In production, always add a safeguard that gives up after N attempts and exits with an error, otherwise your deployment hangs silently:

Terminal window
attempts=0
until nc -z 127.0.0.1 5432 2>/dev/null; do
attempts=$((attempts + 1))
if (( attempts >= 30 )); then
echo "PostgreSQL still unavailable after 30 attempts" >&2
exit 1
fi
sleep 2
done

break and continue: control the flow

These two keywords change the course of a loop. continue abandons the current iteration and moves on to the next one, it is used to filter. break leaves the loop immediately, it is used to stop as soon as something is found or as soon as a condition makes the rest useless.

The example reads a configuration file: it ignores empty lines and comments (continue), and stops everything when it meets a blocking error (break).

Terminal window
read_count=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue # empty line
[[ "$line" == \#* ]] && continue # comment
if [[ "$line" == FATAL:* ]]; then
echo " blocking error: ${line#FATAL: }"
echo " stopping the read"
break
fi
echo " setting kept: $line"
read_count=$((read_count + 1))
done < backup.conf
echo "Useful lines read before stopping: $read_count"

Actual output:

setting kept: source=/var/www
setting kept: destination=/mnt/backup
setting kept: retention=30
blocking error: disk quota exceeded
stopping the read
Useful lines read before stopping: 3

The proof that break worked: the file contained a retention=60 line after the FATAL: line, and it was never read.

Leave two loops with break 2

By default, break only leaves the innermost loop. In nested loops, that is not enough: you leave the ports loop, but the hosts loop carries on. break 2 leaves two levels at once.

The scenario: look for an unexpected service listening on a list of hosts and ports, and stop everything at the first find.

Terminal window
for host in 127.0.0.1 127.0.0.2; do
for port in 9101 9102 9103; do
if nc -z -w1 "$host" "$port" 2>/dev/null; then
echo " $host:$port OPEN, full scan stopped"
break 2
fi
echo " $host:$port closed"
done
done

With a service actually listening on port 9103 only:

127.0.0.1:9101 closed
127.0.0.1:9102 closed
127.0.0.1:9103 OPEN, full scan stopped

The outer loop never reached 127.0.0.2: both levels were indeed left. continue 2 exists on the same principle: move on to the next host without testing the remaining ports.

The traps that break scripts in production

This section is the most important of the page. Each of these traps was reproduced and its output is real. Most of them do not show on your test machine with clean file names: they blow up the day a file contains a space, a directory is empty, or a counter silently falls back to zero.

Good reflex: shellcheck detects most of these errors before running the script. The SC codes quoted below are the ones it actually reports.

Trap 1: for f in $(ls)

It is the most widespread mistake. The output of ls is text, which Bash then submits to word splitting (splitting on spaces) then to globbing (pattern expansion). Two kinds of damage, not one.

First damage, the splitting. With three files, two of which contain a space:

Terminal window
for f in $(ls); do echo "[$f]"; done
[access.log]
[errors]
[2026.log]
[annual]
[report.log]

Three files, five iterations. The names were cut into pieces, and none of the pieces matches an existing file.

Second damage, more vicious: the globbing of the output. With three files, one of them literally named app-*.log:

[app-a.log]
[app-b.log]
[app-a.log]
[app-b.log]
[app-*.log]

Three files, five iterations, and above all app-a.log and app-b.log processed twice. The name app-*.log printed by ls was expanded again as a pattern. On a loop that compresses or deletes, a double processing is an incident.

The fix is the glob, which suffers from neither problem:

Terminal window
for f in *; do echo "[$f]"; done
[app-a.log]
[app-b.log]
[app-*.log]

Three files, three iterations, names intact. Shellcheck reports the bad form as an error: SC2045 (error): Iterating over ls output is fragile. Use globs.

Trap 2: the glob that matches nothing

Counter-intuitive Bash behaviour: when a pattern matches no file, it is not replaced by an empty list, it is passed as is, as a literal string. The loop therefore runs once, on a file that does not exist.

In a directory without any .log:

Terminal window
for f in *.log; do
echo "Processing: $f"
wc -l < "$f"
done
Processing: *.log
bash: *.log: No such file or directory

The loop ran once, with $f set to the string *.log, and the command failed. On a deletion or move loop, this kind of turn can do damage to a file actually named that way.

The clean fix is shopt -s nullglob: a pattern without a match becomes an empty list, and the loop simply does not run.

Terminal window
shopt -s nullglob
n=0
for f in *.log; do
echo "processing: $f"
n=$((n + 1))
done
echo "iterations: $n"
iterations: 0

That is exactly zero iterations, the expected behaviour. The alternative fix, if you do not want to change the shell options, is to test each entry with [[ -e "$f" ]] || continue at the start of the loop.

Trap 3: command | while read, the subshell that swallows your variables

A pipe creates a subshell. The while loop placed to the right of a | therefore runs in a child process, with its own copy of the variables. Everything you change in it is lost as soon as the loop ends, without the slightest error message.

The example counts the 5xx errors in an access log:

Terminal window
errors=0
grep -E " 5[0-9]{2}$" access.log | while IFS= read -r line; do
errors=$((errors + 1))
echo " [$errors] $line"
done
echo "after the loop: errors=$errors"
[1] 10.0.0.4 - POST /login 500
[2] 10.0.0.2 - GET /api/v1/users 500
after the loop: errors=0

The counter does go up to 2 in the loop, then falls back to 0 right after. It is the classic symptom, and it is hard to diagnose because nothing visibly fails.

The fix is process substitution, done < <(command). The loop then stays in the current shell:

Terminal window
errors=0
while IFS= read -r line; do
errors=$((errors + 1))
echo " [$errors] $line"
done < <(grep -E " 5[0-9]{2}$" access.log)
echo "after the loop: errors=$errors"
[1] 10.0.0.4 - POST /login 500
[2] 10.0.0.2 - GET /api/v1/users 500
after the loop: errors=2

The counter survives. Shellcheck sees the problem and names it precisely: SC2030 (info): Modification of errors is local (to subshell caused by pipeline) then SC2031 (info): errors was modified in a subshell. That change might be lost.

Trap 4: read without IFS= or -r

These two options do not protect the same thing, and many guides recite them without saying so. Here is what each one fixes exactly.

IFS= protects the spaces at the start and end of the line. Without it, read removes the spaces and tabs on the edges. On an indented configuration extract:

Terminal window
while read -r line; do printf "[%s]\n" "$line"; done < sshd-extract.conf
[ListenAddress 10.0.0.5]
[PermitRootLogin no]

The indentation and the trailing spaces have disappeared. With IFS=:

[ ListenAddress 10.0.0.5 ]
[ PermitRootLogin no]

Everything is preserved. An important point, often misunderstood: internal spaces are never a problem when you read into a single variable. It absorbs the rest of the line, spaces included. Only the edges are trimmed.

-r protects backslashes. Without it, read treats the backslash as an escape character. On a file of patterns containing ^error\t[0-9]+, a share path \\file-srv\share and a line ending with a backslash:

Terminal window
while IFS= read line; do printf "[%s]\n" "$line"; done < patterns.txt
[^errort[0-9]+]
[\file-srvshare]
[backup next-line]

Three distinct kinds of damage: the \t was eaten (only t remains), the double backslash was reduced to one, and above all the line ending with \ was merged with the next one. Four lines in, three iterations out. With -r:

[^error\t[0-9]+]
[\\file-srv\share]
[backup \]
[next-line]

Each line is intact and the count is right. Shellcheck recalls the rule: SC2162 (info): read without -r will mangle backslashes. Hence the canonical form combining the two protections: while IFS= read -r line.

Trap 5: hostile file names

This trap is regularly badly explained. You often read that find | while read breaks "because of spaces". It is wrong: with IFS= read -r, a name containing spaces goes through fine, since the variable absorbs the whole line.

The real culprit is the line break in a file name. It is legal on Linux, and it destroys the "one line equals one file" logic. With two files, annual report.log and a file whose name contains a newline:

Terminal window
while IFS= read -r f; do
[ -e "$f" ] && state="exists" || state="NOT FOUND"
printf " [%s] -> %s\n" "$f" "$state"
done < <(find . -type f)
[./annual report.log] -> exists
[./incident] -> NOT FOUND
[2026.log] -> NOT FOUND

The file with spaces goes through without a problem, which confirms that the space is not the issue. On the other hand, the name containing a line break was cut into two ghost iterations, which match no existing file. Two files, three iterations.

The fix is to change the separator: find -print0 ends each name with a null byte, the only character that cannot appear in a file name. On the loop side, read -d '' reads up to that null byte.

Terminal window
while IFS= read -r -d '' f; do
[ -e "$f" ] && state="exists" || state="NOT FOUND"
printf " [%s] -> %s\n" "$f" "$state"
done < <(find . -type f -print0)
[./annual report.log] -> exists
[./incident
2026.log] -> exists

Two files, two iterations, and both exist. The multi-line name is correctly passed on in one piece.

Trap 6: {1..$n} does not expand

Brace expansion happens before variables are replaced. Writing {1..$n} therefore does not produce a range, but the literal string:

Terminal window
n=5
for i in {1..$n}; do printf "[%s] " "$i"; done
[{1..5}]

A single iteration, on an unusable string. Two fixes, both checked:

Terminal window
n=5
for i in $(seq "$n"); do printf "[%s] " "$i"; done
# [1] [2] [3] [4] [5]
for (( i=1; i<=n; i++ )); do printf "[%s] " "$i"; done
# [1] [2] [3] [4] [5]

The for (( )) form is preferable: it avoids a call to seq and stays pure Bash. Shellcheck even detects the bad version on its own: SC2051 (warning): Bash doesn't support variables in brace range expansions.

Which loop for which need

The choice almost always comes down to a simple question: do you know in advance how many items you will process? If so, for. If not, while. If you are waiting for an external event, until.

NeedRecommended loop
Known list (servers, arguments, values)for item in "${list[@]}"
Files of a directoryfor f in *.log with shopt -s nullglob
Known number of iterationsfor i in {1..10}
Index, increment or computation in the conditionfor (( i=0; i<10; i++ ))
Read a file line by linewhile IFS= read -r line; do ... done < file
Read the output of a commandwhile IFS= read -r line; do ... done < <(command)
Walk a tree, hostile nameswhile IFS= read -r -d '' f with find -print0
Wait for a service, a port, a fileuntil condition; do sleep 2; done
Condition depending on a changed counterwhile (( counter < max ))

When not to write a loop

A loop is not always the right answer. Two very common situations deserve giving it up, and the gain is clear.

The command already accepts several arguments. Many Unix tools take a list of files. Writing a loop around them brings nothing, it is just slower (one process per file) and more verbose.

Terminal window
# useless loop: one chmod process per file
for f in *.conf; do chmod 644 "$f"; done
# equivalent, a single process
chmod 644 ./*.conf

The result is strictly identical. The same reasoning applies to rm, cp, chown, or to grep -r which explores a tree without you having to loop over it.

The processing is long and parallelisable. A for loop is sequential: each iteration waits for the previous one. On independent tasks that cost CPU time or network, xargs -P starts several processes in parallel.

Actual measurement on 8 files of about 4 MB, on a machine with 16 cores:

Terminal window
# sequential: 1012 ms
for f in *.log; do gzip -k "$f"; done
# 4 compressions in parallel: 323 ms
printf '%s\0' ./*.log | xargs -0 -P 4 -n 1 gzip -k

Three times faster for a line barely longer. The -0 and the printf '%s\0' keep, along the way, the robustness against exotic file names seen in trap 5. For richer needs (progress bar, remote execution, resuming after an error), GNU parallel goes further.

Troubleshooting

The symptoms below match the traps demonstrated above. The prior reflex remains running the script through shellcheck, which identifies most of these cases on its own.

SymptomLikely causeSolution
More iterations than files, truncated namesfor f in $(ls): word splitting on spaces (SC2045)Use the glob: for f in *
A file is processed twiceThe output of ls contained a pattern expanded again by globbingUse the glob: for f in *
The loop runs once with *.log as its valueNo file matches, Bash passes the literal stringshopt -s nullglob, or [[ -e "$f" ]] || continue
Counter or variable at 0 after the whilecommand | while: the loop runs in a subshell (SC2030/SC2031)while ... done < <(command)
Indentation and trailing spaces goneread without IFS= trims the edges of the linewhile IFS= read -r line
Backslashes eaten, two lines mergedread without -r interprets escapes (SC2162)while IFS= read -r line
Paths "not found" although they existA file name contains a line breakfind -print0 + while IFS= read -r -d '' f
{1..$n} gives a single literal iterationBraces expand before variablesfor (( i=1; i<=n; i++ )) or seq "$n"
break does not leave the outer loopbreak only leaves the innermost levelbreak 2 (or continue 2)

Key points

  • for for a known list, while for a stream of unknown size, until to wait for an external condition.
  • for f in * and never for f in $(ls): the glob does not split on spaces and does not expand patterns again.
  • shopt -s nullglob prevents a pattern without a match from being passed as a literal string.
  • while IFS= read -r line; do ... done < file is the canonical form: IFS= preserves the edge spaces, -r preserves backslashes.
  • done < <(command) and never command | while read: the pipe creates a subshell and your changed variables are lost.
  • find -print0 with while IFS= read -r -d '' as soon as a file name may contain a line break.
  • for (( i=0; i<10; i++ )) for an index or a computation, and to get round {1..$n} which does not expand.
  • break 2 and continue 2 act on the outer loop of nested loops.
  • No loop when the command already accepts several arguments, or when xargs -P can parallelise.

Knowledge check

Check that the essentials of this guide stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn