Bash scripting guide · part 16 of 32
Reading files line by line
The correct while IFS= read -r line loop part by part, the missing last line, the pipe-subshell trap, CSV fields, mapfile and stdin-stealing commands.
The standard way to process a file line by line in bash is this loop:
while IFS= read -r line; do
...
done < file
Every piece of it prevents a specific bug. This page takes it apart, then covers the problems people hit next: a last line that disappears, counters that reset to zero, and commands inside the loop that swallow the input.
Why IFS= and -r
read splits the line using IFS and strips leading and trailing whitespace while doing it,
and it treats backslashes as escape characters, joining a line ending in \ with the next one.
For reading data exactly as it is in the file, you want neither. IFS= (empty, for this one
command) disables the trimming; -r keeps backslashes literal.
What plain read does to a line
printf ' indented line\npath C:\\new\\temp\n' > input.txt
echo "--- plain read:"
while read line; do echo "[$line]"; done < input.txt
echo "--- IFS= read -r:"
while IFS= read -r line; do echo "[$line]"; done < input.txt
--- plain read: [indented line] [path C:newtemp] --- IFS= read -r: [ indented line] [path C:\new\temp]
Plain read lost the indentation and ate the backslashes (\n became n, \t became t).
IFS= read -r delivered both lines byte for byte. Unless you specifically want trimming,
write IFS= read -r every time.
Redirect into the loop, after done
The < file goes after done, so the whole loop reads from one open file and each read
takes the next line. Writing read line < file inside the loop would reopen the file each time
and read the first line forever.
Number the lines of a file
n=0
while IFS= read -r line; do
(( ++n ))
printf '%2d %s\n' "$n" "$line"
done < todo.txt
echo "$n lines"
1 buy milk 2 fix login bug 3 write tests 4 fix logout bug 5 deploy v2.4 5 lines
read returns a non-zero status at end of file, which ends the while. Because the loop
runs in the current shell, n keeps its value after done.
The missing last line
read returns failure when it reaches end of file before a newline, even if it read some
characters. A file whose last line has no trailing newline, common with files written by editors
on Windows or generated by programs, therefore loses that line: the data is in $line, but
the loop body never runs for it.
A last line without a newline is skipped
printf 'alpha\nbeta\ngamma' > list.txt
while IFS= read -r line; do echo "got: $line"; done < list.txt
echo "but \$line still holds: $line"
got: alpha got: beta but $line still holds: gamma
gamma was read into line, but read reported end of file, so the body did not run for it.
Process a final line without a newline
printf 'alpha\nbeta\ngamma' > list.txt
while IFS= read -r line || [[ -n $line ]]; do
echo "got: $line"
done < list.txt
got: alpha got: beta got: gamma
When read fails, the || checks whether it still read something. If so, the body runs one
last time; on the next round read fails with an empty line and the loop ends. Tools like
wc -l count newlines, so they also report one line less for such files.
The pipe-subshell trap
Each part of a pipeline runs in its own subshell. If the loop is on the right side of a |, it
runs in a child process, and every variable it sets disappears when the pipeline ends. This is
one of the most reported bash "bugs".
A counter set inside a piped loop is lost
count=0
grep fix todo.txt | while IFS= read -r line; do
(( ++count ))
echo "inside: $count $line"
done
echo "after the loop: count=$count"
inside: 1 fix login bug inside: 2 fix logout bug after the loop: count=0
Inside the loop the counter reached 2, but that was the subshell's copy. The main shell's
count never changed.
There are three fixes. Redirect a file directly when there is one. For command output, use
process substitution, < <(command), which feeds the loop through a file descriptor instead of a
pipe, so the loop stays in the current shell (Process substitution). Or, in a script,
set shopt -s lastpipe, which runs the last part of a pipeline in the current shell when job
control is off, as it is in scripts.
Keep the loop in the current shell
count=0
while IFS= read -r line; do
(( ++count ))
done < <(grep fix todo.txt)
echo "process substitution: count=$count"
shopt -s lastpipe
total=0
grep fix todo.txt | while IFS= read -r line; do (( ++total )); done
echo "lastpipe: total=$total"
process substitution: count=2 lastpipe: total=2
Both counters survived. Note the space in < <(: it is a redirection < followed by a process
substitution <(...).
Splitting lines into fields
Give read several variable names and it splits the line by IFS; the last variable gets the
rest of the line. Set IFS for the read alone, so it does not leak into the rest of the
script. Use _ as a throwaway name for fields you skip.
Read CSV fields and skip the header
{
read -r header
while IFS=, read -r id name email dept _; do
[[ $dept == engineering ]] && printf '%-14s %s\n' "$name" "$email"
done
} < data/users.csv
echo "header was: $header"
Alice Moreau alice@example.com Chen Wei chen@example.com Fatima Haddad fatima@example.com header was: id,name,email,dept,joined
The braces group two commands so they share one input. The first read consumed the header
line, and the loop got the rest. _ took the joined column. This is fine for simple CSV
without quoted commas; for real CSV use a proper parser.
mapfile: the whole file into an array
When you need all the lines at once, mapfile -t arr < file (also called readarray) reads
each line into an array element, -t removing the newline. It is faster than a read loop and
handles a missing final newline correctly. -s N skips N lines and -n N reads at most N.
Read lines into an array
mapfile -t lines < data/poem.txt
echo "${#lines[@]} lines; the last is: ${lines[-1]}"
mapfile -t -s 1 -n 3 rows < data/sales.csv
printf 'row: %s\n' "${rows[@]}"
printf 'alpha\nbeta\ngamma' | { mapfile -t m; echo "no final newline: ${#m[@]} elements"; }
4 lines; the last is: the fox runs on. row: 2026-03-01,north,widget,12,4.50 row: 2026-03-01,south,gadget,3,19.99 row: 2026-03-02,east,widget,7,4.50 no final newline: 3 elements
-s 1 -n 3 skipped the CSV header and read the next three rows. The last line shows mapfile
keeping gamma even without a trailing newline, the case that tripped up the read loop.
Commands that steal the loop's input
Everything inside a while read loop shares its standard input. A command in the body that
reads stdin, such as ssh, ffmpeg, another read, or a cat without arguments, consumes the
rest of the file, and the loop ends after one iteration. Give the loop its own file descriptor
with read -u 3 and 3< file, or give the inner command < /dev/null (ssh -n does that).
A command in the body eats the remaining lines
while read -r host ip; do
echo "checking $host"
ssh server 'echo " remote saw: $(cat | wc -l) lines of stdin"'
done < data/hosts.txt
echo "--- with a separate descriptor:"
while read -r -u 3 host ip; do
echo "checking $host ($ip)"
done 3< data/hosts.txt
checking web01 remote saw: 3 lines of stdin --- with a separate descriptor: checking web01 (10.0.0.11) checking web02 (10.0.0.12) checking db01 (10.0.0.21) checking cache01 (10.0.0.31)
ssh (here to the sandbox itself) forwarded the loop's stdin to the remote command, which
read the three remaining host lines, so the first loop ran only once. With the file on
descriptor 3 and read -u 3, the loop's data is out of reach of anything reading stdin, and all
four hosts are processed.
Windows line endings
A file with CRLF endings, like the fixture's data/windows.txt, leaves a carriage return at
the end of every line read returns. It is invisible when printed, but breaks comparisons and
shows up when the value is followed by more text. Strip it in the loop with ${line%$'\r'},
or convert the file first.
Strip carriage returns while reading
while IFS= read -r line; do
[[ $line == "line one" ]] && echo "matched as-is"
clean=${line%$'\r'}
[[ $clean == "line one" ]] && echo "matched after stripping \\r"
printf '%q -> %q\n' "$line" "$clean"
done < data/windows.txt
matched after stripping \r $'line one\r' -> line\ one $'line two\r' -> line\ two
printf %q makes the hidden \r visible. The raw line never equals line one; after
${line%$'\r'} it does. tr -d '\r' or sed 's/\r$//' fix the file itself.
Related
readRead one line from standard input and split it into shell variables.mapfileRead lines from standard input into an indexed array, one line per element (also called readarray).catConcatenate files and print them to standard output; also the quickest way to see a short file.wcCount lines, words, characters and bytes in files or standard input.trTranslate, squeeze or delete characters from standard input; for case changes, line endings and stripping characters.sshLog in to a remote machine or run a command on it over an encrypted connection; also forwards ports and jumps through hosts.- Loopsfor over lists, files and ranges, C-style for, while and until, break and continue, retry loops, and redirecting a whole loop's input or output.
- Word splittingWhen bash splits expansion results into separate words, how IFS controls it, and why
for line in $(cat file)does not loop over lines. - Process substitutionUse <(cmd) and >(cmd) to hand a command's output to a program that wants a file name, keep while-read loops in the current shell, and avoid its timing traps.
- Subshells and command groupingThe difference between ( ... ) and { ...; }, what a subshell inherits and what it cannot change, which constructs create one, and what that costs.
- RedirectionHow >, >>, 2>, 2>&1, &>, < and exec move a command's input and output between files and file descriptors, and why the order matters.
- ArraysIndexed arrays in bash, creating, reading, appending, looping, slicing and removing elements, and using them to build command lines safely.