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
forloop over a list, an array, a file glob and a numeric range - Write a C-style
forloop withfor (( i=0; i<10; i++ )) - Use
while IFS= read -rto read a file or a CSV line by line - Wait for a service with
until - Control the flow with
break,continueandbreak 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
.logfiles of a directory - read a CSV inventory and act only on the production machines
- loop over the output of a command such as
systemctlorfind - 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.
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" fidoneActual output:
127.0.0.1: reachable10.255.255.1: UNREACHABLENote 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.
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)) fidone
echo "Summary: $unreachable/${#servers[@]} server(s) unreachable"Actual output:
127.0.0.1: reachable 10.255.255.1: UNREACHABLE 127.0.0.2: reachableSummary: 1/3 server(s) unreachableThe 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.
shopt -s nullglobcompressed=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.gz3 file(s) compressedkeep.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.
for i in {1..5}; do printf "%d " "$i"doneecho# 1 2 3 4 5
for i in {0..20..5}; do # step of 5 printf "%d " "$i"doneecho# 0 5 10 15 20Watch 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.
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" fidoneActual output (the server never answers, the four attempts are used up):
Attempt 1/4 on 10.255.255.1... failed, retrying in 2sAttempt 2/4 on 10.255.255.1... failed, retrying in 4sAttempt 3/4 on 10.255.255.1... failed, retrying in 6sAttempt 4/4 on 10.255.255.1... final failure after 4 attemptsInside 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:
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/sdc1The 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.
while IFS= read -r line; do printf "[%s]\n" "$line"done < sshd-extract.confBreakdown:
IFS=empties the field separator for the duration ofread, which preserves the spaces and tabs at the start and end of the lineread -rprevents the interpretation of backslashes (\tstays\t, it does not become a tab)< fileredirects 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.
{ 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.csvWith a fleet.csv containing four machines, one of them a staging one:
web01 10.0.0.11 frontendweb02 10.0.0.12 frontenddb01 10.0.0.21 databaseThe 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.
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 activeA 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.
counter=1while (( counter <= 3 )); do echo "Iteration $counter" counter=$((counter + 1))doneIteration 1Iteration 2Iteration 3until: 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.
attempts=0until nc -z 127.0.0.1 8099 2>/dev/null; do attempts=$((attempts + 1)) echo " service not ready yet, attempt $attempts..." sleep 1doneecho "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:
attempts=0until 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 2donebreak 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).
read_count=0while 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 readUseful lines read before stopping: 3The 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.
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" donedoneWith 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 stoppedThe 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:
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:
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:
for f in *.log; do echo "Processing: $f" wc -l < "$f"doneProcessing: *.logbash: *.log: No such file or directoryThe 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.
shopt -s nullglobn=0for f in *.log; do echo "processing: $f" n=$((n + 1))doneecho "iterations: $n"iterations: 0That 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:
errors=0grep -E " 5[0-9]{2}$" access.log | while IFS= read -r line; do errors=$((errors + 1)) echo " [$errors] $line"doneecho "after the loop: errors=$errors" [1] 10.0.0.4 - POST /login 500 [2] 10.0.0.2 - GET /api/v1/users 500after the loop: errors=0The 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:
errors=0while 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 500after the loop: errors=2The 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:
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:
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:
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 FOUNDThe 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.
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 [./incident2026.log] -> existsTwo 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:
n=5for i in {1..$n}; do printf "[%s] " "$i"; done[{1..5}]A single iteration, on an unusable string. Two fixes, both checked:
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.
| Need | Recommended loop |
|---|---|
| Known list (servers, arguments, values) | for item in "${list[@]}" |
| Files of a directory | for f in *.log with shopt -s nullglob |
| Known number of iterations | for i in {1..10} |
| Index, increment or computation in the condition | for (( i=0; i<10; i++ )) |
| Read a file line by line | while IFS= read -r line; do ... done < file |
| Read the output of a command | while IFS= read -r line; do ... done < <(command) |
| Walk a tree, hostile names | while IFS= read -r -d '' f with find -print0 |
| Wait for a service, a port, a file | until condition; do sleep 2; done |
| Condition depending on a changed counter | while (( 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.
# useless loop: one chmod process per filefor f in *.conf; do chmod 644 "$f"; done
# equivalent, a single processchmod 644 ./*.confThe 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:
# sequential: 1012 msfor f in *.log; do gzip -k "$f"; done
# 4 compressions in parallel: 323 msprintf '%s\0' ./*.log | xargs -0 -P 4 -n 1 gzip -kThree 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.
- xargs: build commands from input: the complete reference, including
-Pand-0. - GNU Parallel: run in parallel: when
xargs -Pis no longer enough.
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.
| Symptom | Likely cause | Solution |
|---|---|---|
| More iterations than files, truncated names | for f in $(ls): word splitting on spaces (SC2045) | Use the glob: for f in * |
| A file is processed twice | The output of ls contained a pattern expanded again by globbing | Use the glob: for f in * |
The loop runs once with *.log as its value | No file matches, Bash passes the literal string | shopt -s nullglob, or [[ -e "$f" ]] || continue |
Counter or variable at 0 after the while | command | while: the loop runs in a subshell (SC2030/SC2031) | while ... done < <(command) |
| Indentation and trailing spaces gone | read without IFS= trims the edges of the line | while IFS= read -r line |
| Backslashes eaten, two lines merged | read without -r interprets escapes (SC2162) | while IFS= read -r line |
| Paths "not found" although they exist | A file name contains a line break | find -print0 + while IFS= read -r -d '' f |
{1..$n} gives a single literal iteration | Braces expand before variables | for (( i=1; i<=n; i++ )) or seq "$n" |
break does not leave the outer loop | break only leaves the innermost level | break 2 (or continue 2) |
Key points
forfor a known list,whilefor a stream of unknown size,untilto wait for an external condition.for f in *and neverfor f in $(ls): the glob does not split on spaces and does not expand patterns again.shopt -s nullglobprevents a pattern without a match from being passed as a literal string.while IFS= read -r line; do ... done < fileis the canonical form:IFS=preserves the edge spaces,-rpreserves backslashes.done < <(command)and nevercommand | while read: the pipe creates a subshell and your changed variables are lost.find -print0withwhile 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 2andcontinue 2act on the outer loop of nested loops.- No loop when the command already accepts several arguments, or when
xargs -Pcan 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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Robust Bash scripts: set -euo pipefail, trap and shellcheck
A new full quiz with random questions
Work again only on the questions you missed