Writing a Bash loop is easy. Writing one that doesn’t quietly drop the last line of a file, choke on a filename with a space in it, or report a count of zero when it clearly processed three items? That takes a bit more care.
This post is about loops in real scripts. We’ll cover for loops over arguments, arrays and globs, while and until with actual admin tasks, break and continue in nested loops, and then the five bugs that catch nearly everyone at some point. Each one comes with the broken version, the real output, and the fix.
Everything was tested on Ubuntu 26.04.1 LTS (Bash 5.3.9) and RHEL 10.2 (Bash 5.2.26). If you just need the loop basics first, read Bash conditionals and loops, then come back.
Bash for loops over arguments, arrays and globs
A for loop walks through a list, one item at a time. The interesting part is where that list comes from. In real scripts it’s usually one of three places: the script’s arguments, an array, or a filename pattern.
Looping over script arguments
Here’s a tiny script that shows the three ways people loop over arguments. Only one of them is right.
#!/usr/bin/env bash
echo "Got $# arguments"
echo '--- "$@"'
for arg in "$@"; do echo "[$arg]"; done
echo '--- $*'
for arg in $*; do echo "[$arg]"; done
echo '--- "$*"'
for arg in "$*"; do echo "[$arg]"; done
./args.sh web01 "db server" '*.log'
Got 3 arguments
--- "$@"
[web01]
[db server]
[*.log]
--- $*
[web01]
[db]
[server]
[*.log]
--- "$*"
[web01 db server *.log]
Three arguments went in. "$@" gave back exactly three. Unquoted $* split db server into two items, and it would also expand *.log into filenames if any matched in the current folder. "$*" glued everything into one string.
So the rule is simple: for arg in "$@", with the quotes, every time. You can even shorten it to for arg; do, which means the same thing.
Looping over an array
Arrays work the same way. Quote the expansion and each element stays whole. This one checks whether a few tools are installed:
#!/usr/bin/env bash
tools=(curl git python3)
for t in "${tools[@]}"; do
if command -v "$t" &>/dev/null; then
echo "$t: installed"
else
echo "$t: missing"
fi
done
On Ubuntu everything was there. On a minimal RHEL 10 install, Git wasn’t:
curl: installed
git: missing
python3: installed
Handy as the first lines of a setup script. Fail early and say exactly what’s missing, rather than crashing halfway through.
Looping over filename globs
For files, let Bash do the matching with a glob. It handles spaces in names correctly, as long as you quote "$f" inside the loop:
for f in logs/*.log; do
echo "$f -> $(wc -l < "$f") lines"
done
logs/app.log -> 2 lines
logs/web server.log -> 1 lines
One catch. When a glob matches nothing, Bash hands you the pattern itself:
for f in none/*.log; do echo "got: $f"; done
got: none/*.log
Your script then tries to open a file literally named *.log. Turn on nullglob and an empty match means the loop body simply doesn’t run:
shopt -s nullglob
for f in none/*.log; do echo "never runs: $f"; done
No output, which is exactly what you want.
Bash while and until loops with real admin examples
while runs as long as a condition is true. until runs as long as it’s false. In admin scripts you’ll mostly use while to read through data and until to wait for something.
while: list real user accounts
A while read loop with a custom IFS can split each line into fields. Here it splits /etc/passwd on colons and keeps only human accounts:
#!/usr/bin/env bash
while IFS=: read -r user _ uid _ _ home shell; do
(( uid >= 1000 && uid < 65000 )) || continue
printf '%-8s uid=%-5s home=%-12s shell=%s\n' "$user" "$uid" "$home" "$shell"
done < /etc/passwd
./accounts.sh
asif uid=1000 home=/home/asif shell=/bin/bash
Each _ is a throwaway variable for fields we don’t need. The last variable, shell, gets whatever is left on the line.
for or while? Check disk usage on a few mount points
#!/usr/bin/env bash
limit=80
for mnt in / /home /var /tmp; do
pct=$(df --output=pcent "$mnt" | tail -1 | tr -dc '0-9')
(( pct >= limit )) && echo "ALERT $mnt ${pct}%" || echo "ok $mnt ${pct}%"
done
./disk.sh
ok / 1%
ok /home 1%
ok /var 1%
ok /tmp 1%
Yep, that one is a for loop, because the list of mount points is fixed. That’s the rule of thumb really. Known list, use for. Unknown amount of input, or a condition you keep checking, use while.
until: wait for a lock to clear
Say two deploy jobs must never run at the same time. The second one should wait until the first removes its lock file. That’s a natural fit for until:
#!/usr/bin/env bash
lock=/tmp/deploy.lock
touch "$lock"; ( sleep 3; rm -f "$lock" ) & # simulate another job
until [[ ! -e $lock ]]; do
echo "$(date +%T) another deploy is running, waiting..."
sleep 1
done
echo "$(date +%T) lock released, deploying"
./lock.sh
18:23:21 another deploy is running, waiting...
18:23:22 another deploy is running, waiting...
18:23:23 another deploy is running, waiting...
18:23:24 lock released, deploying
This works, but it would wait forever if the lock never cleared. We’ll fix that properly in the polling section further down.
break, continue and nested loops in Bash
continue skips to the next item. break leaves the loop. Simple enough on their own. Where it gets interesting is with nested loops.
This script checks every server against every port. Web servers don’t run PostgreSQL, so port 5432 is skipped for them. And if the database server’s SSH check fails, there’s no point continuing at all:
#!/usr/bin/env bash
servers=(web01 web02 db01)
ports=(22 80 5432)
for s in "${servers[@]}"; do
for p in "${ports[@]}"; do
[[ $s == web* && $p == 5432 ]] && continue
echo "check $s:$p"
if [[ $s == db01 && $p == 22 ]]; then
echo "db01:22 failed, stopping all checks"
break 2
fi
done
done
echo "done"
./nested.sh
check web01:22
check web01:80
check web02:22
check web02:80
check db01:22
db01:22 failed, stopping all checks
done
The number after break says how many levels to leave. Plain break would only exit the inner port loop, and the outer loop would keep going. break 2 exits both. continue 2 works the same way: it jumps straight to the next server.
continue is also great for skipping junk when reading config files:
#!/usr/bin/env bash
while IFS= read -r line; do
[[ -z $line || $line == \#* ]] && continue
key=${line%%=*}
[[ $key == log ]] && { echo "found log setting, stop"; break; }
echo "setting: $line"
done < app.conf
./conf.sh
setting: port=8080
setting: user=www
found log setting, stop
Blank lines and # comments are skipped, and the loop stops once it finds the setting it was looking for.
Read a file line by line, including the last line
Here’s a sneaky one. Some files don’t end with a newline character. Files created on Windows, saved by certain editors, or written with printf without a trailing \n are all common examples. And the standard while read loop silently skips their last line.
servers.txt below has three hosts, with no newline after db01:
printf 'web01\nweb02\ndb01' > servers.txt
wc -l servers.txt
2 servers.txt
Even wc -l says 2, because it counts newline characters. Now watch the loop:
#!/usr/bin/env bash
echo "--- plain while read"
while read -r host; do echo "host: $host"; done < servers.txt
echo "--- with || [[ -n \$host ]]"
while IFS= read -r host || [[ -n $host ]]; do echo "host: $host"; done < servers.txt
./lastline.sh
--- plain while read
host: web01
host: web02
--- with || [[ -n $host ]]
host: web01
host: web02
host: db01
db01 just vanished in the first loop. No error, no warning. Picture that in a patching script.
What happens is that read does fill $host with db01, but it returns a failure status because it hit the end of the file before finding a newline. The || [[ -n $host ]] part says “keep going if we still got some text”. It’s the version I’d use for any file you didn’t create yourself.
While we’re here, the other two parts of that line matter too:
--- read line (no IFS=, no -r)
[indented line]
[path C:tempnew]
--- IFS= read -r line
[ indented line]
[path C:\temp\new]
Without IFS=, leading and trailing spaces get stripped. Without -r, backslashes are treated as escape characters and disappear. So the full, safe pattern is:
while IFS= read -r line || [[ -n $line ]]; do
...
done < file.txt
If you just want the whole file in an array, mapfile is shorter and also keeps the last line:
mapfile -t hosts < servers.txt
echo "${#hosts[@]} hosts: ${hosts[*]}"
3 hosts: web01 web02 db01
Loop through files safely with find -print0 and read -d
Globs are fine for one folder. For recursive searches you need find, and this is where a lot of scripts break. Linux filenames can contain spaces, tabs, even newlines. They can also start with a dash. We made a test folder with one of each:
#!/usr/bin/env bash
n=0
for f in $(find weird -type f); do n=$((n+1)); echo "item $n: [$f]"; done
./badfind.sh
item 1: [weird/tab]
item 2: [here.txt]
item 3: [weird/-n.txt]
item 4: [weird/normal.txt]
item 5: [weird/new]
item 6: [line.txt]
item 7: [weird/with]
item 8: [space.txt]
Five files turned into eight items, and half of them are names that don’t exist. Any command run on these would fail, or worse, act on the wrong file.
The fix is to separate names with the null character, which is the one byte a filename can’t contain. find -print0 writes them that way and read -d '' reads up to each null:
#!/usr/bin/env bash
n=0
while IFS= read -r -d '' f; do
n=$((n+1))
printf 'file %d: %q\n' "$n" "$f"
done < <(find weird -type f -print0 | sort -z)
echo "total: $n"
./goodfind.sh
file 1: $'weird/new\nline.txt'
file 2: weird/normal.txt
file 3: weird/-n.txt
file 4: $'weird/tab\there.txt'
file 5: weird/with\ space.txt
total: 5
Five files, five items. printf %q shows the hidden tab and newline so you can see they survived. (Ubuntu sorted -n.txt first. Sort order depends on the locale, so don’t worry if yours differs.)
Notice the < <(find ...) at the end instead of piping find into the loop. That’s on purpose. It keeps the n counter alive after the loop finishes. More on that in the last section.
If you only need to run one command per file, skip the loop entirely: find weird -type f -exec wc -c {} + is safe for any name too. The find command examples post has plenty more.
The leading-dash problem
A file called -n.txt looks harmless until you pass it to a command, which reads it as an option:
cd weird
for f in *.txt; do wc -c "$f"; done
On RHEL:
0 'new'$'\n''line.txt'
0 normal.txt
wc: invalid option -- 'n'
0 tab here.txt
0 with space.txt
Every file got counted except -n.txt, which wc took as an option.
Ubuntu 26.04 gives a slightly different message, error: unexpected argument '-n' found, because its wc comes from a different coreutils build. Same problem either way. Put -- before the filename to say “options end here”:
for f in *.txt; do wc -c -- "$f"; done
0 'new'$'\n''line.txt'
0 normal.txt
0 -n.txt
0 tab here.txt
0 with space.txt
Using ./*.txt instead of *.txt fixes it too, because ./-n.txt no longer starts with a dash. Either way, it’s worth the habit in any script that deletes or moves files.
Why for line in $(command) loses data
This one looks so natural that everybody writes it at some point:
for line in $(some_command); do ...
The problem is that for doesn’t loop over lines. It loops over words. The command output gets split on spaces, tabs and newlines, and then each word is run through glob expansion. Here’s some job output with a blank line and a line starting with *, run in a folder that holds backup.sh and notes.txt:
#!/usr/bin/env bash
out=$(printf 'job one done\n\n* rotate logs\nlast job\n')
echo "--- for line in \$(...)"
for line in $out; do echo "[$line]"; done
echo "--- while read < <(...)"
while IFS= read -r line; do echo "[$line]"; done < <(printf '%s\n' "$out")
./cmdout.sh
--- for line in $(...)
[job]
[one]
[done]
[backup.sh]
[notes.txt]
[rotate]
[logs]
[last]
[job]
--- while read < <(...)
[job one done]
[]
[* rotate logs]
[last job]
Look at the first loop. Lines are broken into words, the blank line is gone, and that * was expanded into the names of files sitting in the current folder. Your script would happily process backup.sh as if it were a line of output. Same result on Ubuntu and RHEL.
The while read version gives exactly four lines, blank one included. For reading command output line by line, use one of these:
while IFS= read -r line; do ...; done < <(command)
mapfile -t lines < <(command)
Looping over words with $(...) is fine when the output really is a list of simple words, like usernames or package names. Just don’t use it for lines.
Build a polling loop with a deadline and maximum attempts
“Wait until the service is up” sounds easy. But a loop that waits for something external needs two limits: a maximum number of attempts and a total time limit. Otherwise a broken deploy just hangs your pipeline forever.
Bash has a built-in SECONDS variable that counts seconds since the script started, which makes deadlines easy:
#!/usr/bin/env bash
url=${1:-http://127.0.0.1:8099/}
deadline=$(( SECONDS + ${2:-20} ))
max=${3:-10}
attempt=0
while :; do
(( attempt++ ))
if curl -fsS --max-time 2 -o /dev/null "$url" 2>/dev/null; then
echo "UP after $attempt attempt(s), ${SECONDS}s"
exit 0
fi
if (( attempt >= max || SECONDS >= deadline )); then
echo "GAVE UP after $attempt attempt(s), ${SECONDS}s" >&2
exit 1
fi
echo "attempt $attempt: not ready, retrying in 2s"
sleep 2
done
Three things keep this from hanging:
--max-time 2limits each individual check, so one stuck request can’t eat the whole budgetmaxcaps the number of attemptsdeadlinecaps the total time
First, with nothing listening on port 8099 and a 6-second deadline:
./poll.sh http://127.0.0.1:8099/ 6 10
attempt 1: not ready, retrying in 2s
attempt 2: not ready, retrying in 2s
attempt 3: not ready, retrying in 2s
GAVE UP after 4 attempt(s), 6s
echo $?
1
Then with a test web server that starts five seconds later (python3 -m http.server 8099):
./poll.sh http://127.0.0.1:8099/ 30 10
attempt 1: not ready, retrying in 2s
attempt 2: not ready, retrying in 2s
attempt 3: not ready, retrying in 2s
UP after 4 attempt(s), 6s
echo $?
0
That exit code is what makes it useful. Drop it into a deploy pipeline after a service restart and the pipeline fails cleanly when the app doesn’t come back. Swap the curl line for whatever “ready” means for you: systemctl is-active --quiet nginx, pg_isready, or a check that a file exists.
Bonus: the timeout command from coreutils can put an overall limit on any command, including a whole loop. timeout 30 bash -c 'until curl -fs localhost; do sleep 2; done' gives up after 30 seconds and exits with status 124.
Avoid pipeline subshell bugs when counting in a loop
Last one, and it’s a real head-scratcher the first time. You count lines in a loop, the loop clearly runs, and the count afterwards is zero:
#!/usr/bin/env bash
count=0
grep -h . logs/*.log | while read -r line; do
(( count++ ))
done
echo "pipe: count=$count"
count=0
while read -r line; do
(( count++ ))
done < <(grep -h . logs/*.log)
echo "proc-sub: count=$count"
shopt -s lastpipe
count=0
grep -h . logs/*.log | while read -r line; do
(( count++ ))
done
echo "lastpipe: count=$count"
./count.sh
pipe: count=0
proc-sub: count=3
lastpipe: count=3
Why zero? In Bash, every part of a pipeline runs in its own subshell. That’s a child copy of your script. The while loop after the | increments its own copy of count, then exits, and the copy goes with it. The parent script never sees the change.
Three ways to fix it:
- Process substitution:
done < <(command). The loop runs in the main shell and reads from the command. This is the one I reach for first. - Read from a file directly:
done < file.txt. No pipe, no problem. shopt -s lastpipe: runs the last part of a pipeline in the main shell. It works in scripts, but not at an interactive prompt where job control is on, so don’t be surprised if testing it in your terminal fails.
If you’d like more background on how pipes connect commands, see pipes and redirection in Linux.
Wrapping up
Most loop bugs come down to the same thing: Bash splitting text somewhere you didn’t expect it to. Quote your expansions, read files and command output with while IFS= read -r, use null separators for filenames, and give every waiting loop a way out. Do that and your loops will behave the same on a laptop, an Ubuntu server and a RHEL box.
Run shellcheck on your scripts too. It flags most of the problems above before they bite (sudo apt install shellcheck on Ubuntu, or from EPEL on RHEL). And if the variable tricks used here, like ${line%%=*}, are new to you, Bash variables explained covers them.
Frequently asked questions
How do I loop through all arguments in a Bash script?
Use for arg in "$@"; do ... done. The quoted "$@" keeps each argument as a separate item, even if it contains spaces. Unquoted $* or $@ splits arguments on spaces and expands wildcards.
Why does my while read loop skip the last line?
The file doesn’t end with a newline. read still fills the variable but returns a failure status, so the loop stops before processing it. Use while IFS= read -r line || [[ -n $line ]] to catch that final line.
How do I loop through files with spaces in their names?
In one folder, use a glob like for f in *.txt and quote "$f". Recursively, use find . -type f -print0 with while IFS= read -r -d '' f, fed through < <(find ...). Never loop over $(ls) or $(find).
Why is my variable empty after a while loop in Bash?
You probably piped a command into the loop. Each part of a pipeline runs in a subshell, so changes made inside the loop are lost when it ends. Use done < <(command) instead, or enable shopt -s lastpipe in the script.
How do I exit a nested loop in Bash?
Use break 2 to leave two levels of loops at once, or break 3 for three. continue 2 skips to the next pass of the outer loop.
How do I add a timeout to a Bash loop?
Set a deadline with the built-in SECONDS variable, for example deadline=$(( SECONDS + 60 )), and check (( SECONDS >= deadline )) on each pass. Add an attempt counter as well, and limit each check with something like curl --max-time or the timeout command.
What is the difference between for and while loops in Bash?
for goes through a fixed list of items: arguments, an array, or files matching a glob. while keeps running as long as a condition is true, which makes it the right choice for reading input line by line or waiting on something.