opscape

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
output
--- 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"
output
 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"
output
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
output
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"
output
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"
output
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"
output
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"; }
output
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
output
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
output
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