Linux Basic — Looping Command Output
Looping Command Output
When you need to process the output of a command line by line, the right form is while IFS= read -r line; do … done < <(cmd). The naive form cmd | while read line works too — but bites you on variable scope.
The Canonical Form
while IFS= read -r line; do
echo "[$line]"
done < /tmp/input.txt
Each piece matters:
| Part | Why |
|---|---|
IFS= |
empty IFS preserves leading/trailing whitespace on the line |
read -r |
-r disables backslash escapes (treats \n literally) |
< file |
redirect stdin from a file |
The combination “IFS= read -r” is the idiomatic safe read — memorise it.
Reading From a Command, Not a File
Two options. The naive way:
some_cmd | while IFS= read -r line; do
echo "$line"
done
This works for printing, but has a famous trap: the while loop runs in a subshell, and variable assignments inside it don’t persist:
count=0
seq 1 5 | while IFS= read -r n; do
(( count++ ))
done
echo "$count"
# 0 ← whaaaat
The while is on the right of a pipe, so Bash forks a subshell for it. count++ happens, but in the subshell. When the subshell ends, the change is lost.
The fix: process substitution.
count=0
while IFS= read -r n; do
(( count++ ))
done < <(seq 1 5)
echo "$count"
# 5 ← works! no subshell
< <(cmd) makes the current shell read directly from a pipe to cmd. No subshell. All assignments persist.
(Bash 4.2+ has shopt -s lastpipe which makes the last pipeline element run in the current shell — but only in non-interactive scripts. Process substitution is more portable.)
Reading Multiple Fields per Line
read can split each line into multiple variables:
echo "alice 30 admin" | while read -r name age role; do
echo "name=$name age=$age role=$role"
done
# name=alice age=30 role=admin
If there are more fields than variables, the last variable gets the rest:
echo "alice 30 admin extra fields" | while read -r name age rest; do
echo "name=$name age=$age rest=[$rest]"
done
# name=alice age=30 rest=[admin extra fields]
Use a custom IFS to split on something other than whitespace:
while IFS=, read -r name age role; do
echo "$name | $age | $role"
done < /tmp/users.csv
The IFS=, here only applies to that single read.
Reading Into an Array
echo "a:b:c:d" | { IFS=: read -ra parts; echo "${parts[1]}"; }
# b
Or all lines into an array — use mapfile (faster and clearer than a loop):
mapfile -t lines < /tmp/input.txt
echo "Got ${#lines[@]} lines"
# From a command
mapfile -t hosts < <(getent hosts | awk '{print $2}')
mapfile -t strips trailing \n. For NUL-delimited input use mapfile -d ''.
Don’t Forget -r
Without -r, read interprets backslashes as escape characters:
echo 'C:\Users\alice' | read -r path; echo "$path"
# C:\Users\alice
echo 'C:\Users\alice' | read path; echo "$path"
# C:Usersalice ← backslashes interpreted!
Always -r. There’s almost never a reason to omit it.
The Last-Line-No-Newline Problem
read returns true while it has a complete line ending in \n. If the last line lacks a newline, read reads the data into the variable but returns false:
printf 'first\nsecond' | while IFS= read -r line; do
echo "[$line]"
done
# [first] ← "second" is lost!
The fix — use the line and the EOF case:
printf 'first\nsecond' | while IFS= read -r line || [[ -n "$line" ]]; do
echo "[$line]"
done
# [first]
# [second]
The || [[ -n "$line" ]] clause handles “read returned false but we got data.”
Looping a Fixed Number of Times
Don’t pipe seq to a loop for this — use a C-style or brace expansion:
# C-style — variable bounds OK
for ((i=0; i<10; i++)); do
echo "$i"
done
# Brace expansion — literal bounds only
for i in {1..10}; do
echo "$i"
done
for ((…)) is the right tool for counted iteration. while read is for streamed input.
A Real Example — Process Each Line of a Log
errors=0
warnings=0
while IFS= read -r line; do
case "$line" in
*ERROR*) (( errors++ )) ;;
*WARNING*) (( warnings++ )) ;;
esac
done < /var/log/app.log
echo "errors=$errors warnings=$warnings"
< file (direct redirect) — no subshell, counts persist.
For “tail-and-process”:
tail -F /var/log/app.log | while IFS= read -r line; do
[[ "$line" == *ERROR* ]] && echo "[$(date)] $line" >> errors.log
done
Here the subshell is fine — we’re not modifying any outer-scope variables.
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
cmd \| while read line; do count++ |
while …; done < <(cmd) |
pipe puts loop in subshell |
read line (no -r) |
read -r line |
backslashes consumed |
while read line (no IFS=) |
while IFS= read -r line |
leading/trailing whitespace stripped |
cat file \| while read … |
while …; done < file |
useless use of cat
|
| Missing tail line without newline | while … \|\| [[ -n "$line" ]] |
last line silently dropped |
Next → Glob Patterns