Loops · about 45 min

Reading lines and fields safely

Why this matters

So much of the data you will handle in scripts is line-oriented text: log files, CSV exports, /etc/passwd, lists of host names, the output of other commands. "Do something for every line" is a while read loop. It looks simple, but the naive version silently mangles data: it strips leading spaces, eats backslashes, drops the last line of files that lack a final newline, and can swallow the input of commands inside the loop. This lesson gives you the correct idiom and explains every part of it.

The idiom

while IFS= read -r line; do
    printf '%s\n' "$line"
done < input.txt

Piece by piece:

Here is the difference IFS and -r make, on a line with leading spaces and backslashes:

$ printf '  path: C:\\temp\\new\n' > t.txt
$ cat t.txt
  path: C:\temp\new
$ read line < t.txt; echo "[$line]"
[path: C:tempnew]
$ IFS= read -r line < t.txt; echo "[$line]"
[  path: C:\temp\new]

Splitting fields with read

Give read several variable names and it splits the line into fields using IFS, one field per variable; the last variable gets the rest of the line:

$ echo "alice 42 likes tea and cake" | { read -r name age rest; echo "$name|$age|$rest"; }
alice|42|likes tea and cake

Set IFS to a delimiter to parse structured lines. /etc/passwd uses colons:

while IFS=: read -r user _ uid _ _ home shell; do
    echo "$user uses $shell"
done < /etc/passwd
root uses /bin/bash
daemon uses /usr/sbin/nologin
...
learner uses /bin/bash

_ is a conventional throwaway name for fields you do not need. For a CSV without quoted fields, IFS=, does the same. (CSV with quoted commas inside fields needs a real parser; awk or Python handle those better.)

A detail of IFS splitting: whitespace characters in IFS (space, tab, newline) are trimmed and runs of them count as one separator; other characters (like : or ,) separate exactly, so a,,b gives an empty middle field. That is what you want for data files.

The missing last newline

A text file should end with a newline, but many do not (files written by some editors, Windows tools, or printf without \n). On such a final line, read fills the variable but returns 1, so the loop ends without running the body for it:

$ printf 'one\ntwo' | while IFS= read -r l; do echo "got $l"; done
got one

two vanished. The fix is to also run the body when read failed but did read something:

while IFS= read -r line || [[ -n $line ]]; do
    ...
done

Feeding loops: files, pipes and process substitution

Three ways to give a loop its input:

while IFS= read -r line; do ...; done < file.txt          # a file
grep ERROR app.log | while IFS= read -r line; do ...; done  # a pipe
while IFS= read -r line; do ...; done < <(grep ERROR app.log)  # process substitution

The pipe version has a catch: each side of a pipeline runs in its own subshell (a forked copy of the shell), so variables set inside the loop are lost when it ends:

count=0
grep ERROR app.log | while IFS= read -r line; do (( count += 1 )); done
echo "$count"        # prints 0!

The loop incremented the copy's count, and the copy exited. The next lesson looks at this trap in detail; the short fix is process substitution, < <(command), which keeps the loop in the current shell. (<(command) gives a file name like /dev/fd/63 connected to the command's output, and the first < redirects it into the loop.)

Commands inside the loop that read stdin

Everything in the loop body inherits the loop's standard input. A command in the body that reads stdin will eat the rest of your file:

while IFS= read -r host; do
    ssh "$host" uptime        # ssh reads stdin and swallows the remaining host names
done < hosts.txt

Only the first host gets processed. Fix it by giving that command its own input (ssh "$host" uptime < /dev/null, or ssh -n), or by reading the file on another file descriptor:

while IFS= read -r -u 3 host; do
    ssh "$host" uptime
done 3< hosts.txt

3< hosts.txt opens the file as descriptor 3 for the loop, and read -u 3 reads from it, leaving stdin alone.

A worked example

Open the shell for this lesson. sales.csv is already there. Summing a column per region:

#!/bin/bash
north=0 other=0
{
    read -r _header
    while IFS=, read -r region rep amount || [[ -n $region ]]; do
        if [[ $region == north ]]; then
            (( north += amount ))
        else
            (( other += amount ))
        fi
    done
} < sales.csv
echo "north: $north"
echo "other: $other"
$ bash sums.sh
north: 1699
other: 1550

The { ...; } group shares one redirection: the first read consumes the header line, and the loop continues from line 2 on the same open file. Groups run in the current shell, so north and other survive.

Common mistakes

Exercises

ifs-r checks what each part of the idiom protects. users parses passwd-format data; one test case has no newline at the end. The csv-report task has a header to skip, names with spaces, and an unterminated last line.

Recap

Going further: help read documents -d (another delimiter instead of newline), -a (into an array, module 15), -n and -t. Try while IFS= read -r -d '' f; do ...; done < <(find . -print0) again after module 14: it is the fully safe way to read file names.

Exercises

Exercise 1 · quiz

What do IFS= and -r protect?

A file has this line (two leading spaces, a backslash in the middle):

C:\temp\new

Which loop prints the line back exactly, leading spaces and backslashes included?

Answer
  • while IFS= read -r line; do printf '%s\n' "$line"; done < f

Without -r, read treats backslash as an escape character and removes it (C:tempnew). With the default IFS, read strips leading and trailing whitespace from the line. IFS= (empty, set only for this one read command) disables the trimming, and -r keeps backslashes. printf '%s\n' prints the value as-is; echo would also work here, but printf is safe even for lines like -n that echo would treat as an option.

Exercise 2 · write code

Human users from a passwd file

Standard input is in the format of /etc/passwd: seven fields separated by colons (name:password:uid:gid:comment:home:shell). Print one line for every account whose uid is 1000 or more and whose shell does not end in nologin, in the form:

NAME (uid UID) home=HOME

in input order. Finally print N users. Use while IFS=: read -r ... to split the fields. The last line of the input may lack a final newline; it must still be processed.

File: script.sh

Starting point

#!/bin/bash
while read line; do
    echo "$line"
done

Example

input

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
alice:x:1000:1000:Alice Smith,,,:/home/alice:/bin/bash
backup:x:1001:1001::/var/backups:/usr/sbin/nologin
bob:x:1002:1002:Bob:/home/bob:/bin/zsh

expected output

alice (uid 1000) home=/home/alice
bob (uid 1002) home=/home/bob
2 users

Example

input

carol:x:1500:1500:Carol:/home/carol:/bin/bash
dave:x:2000:2000:Dave:/home/dave:/bin/bash

expected output

carol (uid 1500) home=/home/carol
dave (uid 2000) home=/home/dave
2 users
Hint 1

IFS=: read -r name pw uid gid comment home shell puts each field in its own variable.

Hint 2

Test with (( uid >= 1000 )) && [[ $shell != *nologin ]].

Hint 3

To handle a last line with no newline: while IFS=: read -r name ... || [[ -n $name ]]; do. read fails at that line but still fills the variables.

Reference solution
#!/bin/bash
count=0
while IFS=: read -r name pw uid gid comment home shell || [[ -n $name ]]; do
    if (( uid >= 1000 )) && [[ $shell != *nologin ]]; then
        echo "$name (uid $uid) home=$home"
        (( count += 1 ))
    fi
done
echo "$count users"

Exercise 3 · task in a shell

Report from a CSV file

/work/sales.csv has a header line and then lines of region,rep,amount. Some rep names contain spaces, and the file's last line has no trailing newline.

Write /work/report.sh that reads sales.csv with a while IFS=, read -r ... loop, skips the header, and writes to /work/big-sales.txt one line per sale of 500 or more, formatted REP: AMOUNT (REGION), in file order. Run it.

Expected for the first big sale: Ann Lee: 1200 (north).

Hint 1

Redirect the file into the loop: while ...; do ...; done < sales.csv > big-sales.txt.

Hint 2

Skip the header by reading one line before the loop, inside a group that shares the redirection: { read -r header; while ...; done; } < sales.csv. Or skip it inside the loop with [[ $region == region ]] && continue.

Hint 3

The last line has no newline: use while IFS=, read -r region rep amount || [[ -n $region ]]; do.

Reference solution
cat > report.sh <<'EOF'
#!/bin/bash
{
    read -r header
    while IFS=, read -r region rep amount || [[ -n $region ]]; do
        if (( amount >= 500 )); then
            echo "$rep: $amount ($region)"
        fi
    done
} < sales.csv > big-sales.txt
EOF
bash report.sh