Session 5 · Computer Systems

Reading and writing files; line endings

Redirections, reading a file line by line, and the three things real files break: no newline after the last line, extra empty lines and Windows CRLF line endings – how to see them with cat -et and the one loop pattern that handles them all.

20 min read> vs >>while IFS= read -r lineLF vs CRLFLast line without newlinecat -et and od -cThe universal loop pattern

Most shell tasks – in class, in the practical test and in your assignment – read a file, do something with every line and write the result somewhere. The loop itself is short. What breaks it is the file: a last line without a newline, a few empty lines at the end, or a file that was saved on Windows. All three are invisible in an editor and all three change what your script does.

Here is the symptom in one line. A file holds a single word, banana, and wc -l says it has 0 lines. By the end of this session you will know why, how to see it, and the one loop pattern that handles all of these files correctly.

The whole session in one sentence: a line is the text before a newline character, and Unix tools only count \n as a newline – so a file that ends without one, or uses \r\n, is not what your loop thinks it is.

Reading and writing files

Redirections

A command reads standard input and writes standard output. Redirections point those streams at files instead of the terminal. You have met some of them already; these are the ones file processing needs.

OperatorMeaning
cmd < file.txtInput comes from the file.
cmd > file.txtOutput goes to the file. The old content is deleted first (a missing file is created).
cmd >> file.txtOutput is appended to the end of the file; the old content stays.
> file.txtOn its own: empty the file, or create an empty one.
cmd << EOF … EOFA multi-line block written in the script is the input (here document).
cmd <<< "text"One string or variable is the input (here string).
cmd >&2Output goes to standard error – use it for error messages.
step 1echo apple > fruits.txtfruits.txtapplefile createdstep 2echo pear > fruits.txtfruits.txtpearemptied first – apple is gonestep 3echo plum >> fruits.txtfruits.txtpearplumadded at the end
Fig. 1 · A single > truncates before it writes; >> appends.
echo "apple" > fruits.txt     # fruits.txt: apple
echo "pear"  > fruits.txt     # fruits.txt: pear         (apple is gone)
echo "plum" >> fruits.txt     # fruits.txt: pear, plum   (appended)

The two combine into the standard shape for writing a result file: empty it once before the loop, then append inside the loop. Without the first line, running the script twice would leave the first run's output in the file, with the second run's output after it.

> "$output_file"                     # empty it once, before the loop

while IFS= read -r line || [ -n "$line" ]; do
    echo "$line" >> "$output_file"   # append inside the loop
done < "$input_file"

Line by line

For real files, this is the loop header to use. Every part of it has a job:

while IFS= read -r line || [ -n "$line" ]; do
    ...
done < "$file"
PartWhat it does
done < "$file"The whole loop reads from the file; each read takes the next line.
-rA backslash in the line stays a backslash instead of being treated as an escape.
IFS=Leading and trailing spaces are kept. Leave it out when you want them trimmed.
|| [ -n "$line" ]The last line is processed even if no newline follows it – see Trap 1.

When a line is made of fields, such as Anna Kiss;87, set IFS to the separator and give read one variable per field. If there are more fields than variables, the rest of the line goes into the last variable.

while IFS=';' read -r name points || [ -n "$name" ]; do
    echo "$name: $points"
done < grades.txt

What is a line?

In a text file a line is ended by a newline character. Which character that is depends on the system:

SystemLine endingWritten as
Linux, macOSLF (line feed)\n
WindowsCR LF (carriage return + line feed)\r\n

The names come from typewriters: the carriage return pushes the carriage back to the start of the line, the line feed rolls the paper up by one line. Windows kept both; Unix systems kept only the line feed. Bash and the Unix tools – read, grep, wc – treat \n as the end of a line, and in a well-formed file the last line ends with one too.

The four test files of this session (numbers_*.txt) contain the same three numbers – 10, 20, 30 – and still behave differently, because their bytes differ (Fig. 2).

filebytes on diskthe trapnumbers_ok.txt10\n20\n30\nregular filenumbers_no_newline.txt10\n20\n30\nno final \nnumbers_empty_lines.txt10\n20\n30\n\n\ntwo empty linesnumbers_crlf.txt10\r\n20\r\n30\r\n\r before each \nBlue: \n (LF), the end of a line. Red: \r (CR), or a \n that should be there but is not (dashed).
Fig. 2 · Same numbers, four different files. Only the first one is what a naive loop expects.

Plain cat doesn't show any of this. Use cat -et: it prints $ at every newline, ^M for a carriage return and ^I for a tab. A last line without $ has no newline. (On Linux cat -A is the same.) For the raw bytes, use od -c.

cat -et file.txt             # $ at every line end, ^M for \r, ^I for a tab
od -c file.txt               # every byte, \n and \r included
tail -c 1 file.txt | od -c   # only the last byte

cat -et on three of the files

$ cat -et numbers_ok.txt
10$
20$
30$
$ cat -et numbers_crlf.txt
10^M$
20^M$
30^M$
$ cat -et numbers_no_newline.txt
10$
20$
30$ 

Trap 1: no newline after the last line

This is the most common one. Windows Notepad and several editors don't put a newline at the end of the file, and in a script echo -n or printf '%s' produce the same. Three things go wrong:

SymptomFix
The while read loop skips the last line: read does fill the variable, but returns failure because it found no newline.while IFS= read -r line || [ -n "$line" ]
wc -l reports one less: it counts newline characters, not lines.Count in the loop, or with grep -c ''.
Appending with >> glues the new line to the last one.Add the missing newline before appending.

Fig. 3 walks through the loop on the file 20\n30. The second read gets 30 and returns failure in the same step. With a plain while read condition that failure ends the loop and 30 is lost; the || [ -n "$line" ] part says "unless something was read".

file: 2 0 \n 3 0 (end of file – no \n after 30)callline =read returns[ -n "$line" ]body runs?1st read"20"0 – found \nnot neededyes2nd read"30"1 – end of filetrueyes – rescued3rd read""1 – end of filefalseno – loop endsWithout || [ -n "$line" ] the loop stops at the 2nd read, and 30 is never processed.
Fig. 3 · The || branch gives an unterminated last line one more pass through the body.
wc -l < numbers_no_newline.txt     # 2 – counts \n characters
grep -c '' numbers_no_newline.txt  # 3 – counts lines

The third symptom destroys data, so it gets its own picture. A registry file stores one name,phone record per line; its last line has no newline; your script appends a new record.

registry.txt (cat -et)anna,0630111$carol,0670555last line: no $, so no \necho dan,… >> filecarol,0670555dan,0630222two records glued together1. add \n if missing2. then echo … >> filecarol,0670555$dan,0630222$
Fig. 4 · Appending to a file without a final newline merges two records into one broken line.

The fix checks the last byte first. tail -c 1 prints it, and command substitution $( ) strips a trailing newline from what it captures – so the result is empty exactly when the file ends in \n.

# $( ) drops a trailing \n, so the result is empty exactly when the file ends in \n
if [ -n "$(tail -c 1 "$file")" ]; then
    echo >> "$file"                   # add the missing newline first
fi
echo "$name,$phone" >> "$file"

Trap 2: extra empty lines

read reads empty lines as well, so the loop runs more times than there are data lines. The count and the average come out wrong, a number check reports "invalid line", a copy script copies blank lines. The fix is to skip them:

if [ -z "$line" ]; then
    continue
fi

Do it after stripping the carriage return (next section): on a Windows file an "empty" line still contains \r, and -z would not see it as empty.

Trap 3: Windows line endings (CRLF)

When a file made on Windows is read on Linux, read cuts at \n and the \r stays at the end of the line. You can't see it in the terminal, but every comparison sees it (Fig. 5).

in the file10\r\nread cuts at \n$line holds10\r=~ ^[0-9]+$ no match== "10" false$(( sum + line )) errorWindows: 10\r\nLinux: 10\nline=${line%$'\r'}removes one \r from the end – the value is 10 again
Fig. 5 · The invisible \r rides along in the variable until you strip it.
What you doWhat happens
[[ $line =~ ^[0-9]+$ ]]False: 10\r doesn't end in a digit.
[ "$line" == "end" ]False: end\r is not end.
sum=$(( sum + line ))Error: invalid arithmetic operator.
IFS=';' read -r name pointsThe \r lands in the last field, points.
The script itself has CRLF/bin/bash^M: bad interpreter or $'\r': command not found.

what it looks like

$ ./sum.sh numbers_crlf.txt
./sum.sh: line 4: 10: syntax error: invalid arithmetic operator (error token is "\r")

$ ./script.sh                         # the script itself was saved with CRLF
-bash: ./script.sh: /bin/bash^M: bad interpreter: No such file or directory

Where it comes from: the file was created on Windows (Notepad, a CSV export from Excel), or git on Windows converted line endings on checkout (core.autocrlf). The course repository has a .gitattributes file that keeps .sh and .txt files LF everywhere – and leaves the deliberately broken test files alone.

FixEffect
${line%$'\r'}Inside the loop: removes one \r from the end of the line. $'\r' is bash notation for the CR character.
tr -d '\r' < in.txt > out.txtDeletes every \r in the file.
tr -d '[:space:]'Deletes every whitespace character, \r included. Also deletes spaces between words – use it on numbers.
dos2unix file.txtThe dedicated tool (in Git Bash; elsewhere it may need installing).
line=${line%$'\r'}               # inside the loop: drop one \r from the end
tr -d '\r' < in.txt > out.txt     # the whole file at once
dos2unix file.txt                 # the dedicated tool, where it is installed

Try it: the naive loop on all four files

The loop you would write first, without any of the fixes:

count=0; sum=0
while read -r number; do
    count=$(( count + 1 ))
    sum=$(( sum + number ))
done < "$file"
FileNaive: linesNaive: sumSafe pattern: sum
numbers_ok.txt36060
numbers_no_newline.txt23060
numbers_empty_lines.txt56060
numbers_crlf.txt3error60

The empty-lines file sums correctly only by luck: an empty variable counts as 0 in arithmetic. The count – and any average – is still wrong.

The universal pattern

Put the three fixes together and you get the loop to start every file-processing script from. Copy it, and only rewrite the "process" part.

read a file line by line – the safe way

while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}             # 1. drop a Windows \r
    if [ -z "$line" ]; then         # 2. skip empty lines
        continue
    fi

    # 3. --- process the line ---
    echo "$line"
done < "$file"
< "$file"done < filethe loop reads itIFS= read -r lineread the line|| keeps the lastlast line kept${line%$'\r'}strip \rWindows files\r removed[ -z "$line" ]skip emptycontinueempty lines droppedecho "$line"processyour code here
Fig. 6 · Every line passes the same three filters before your code sees it.
PartWhat it solves
IFS= read -r lineThe line lands in the variable exactly as in the file (spaces, backslashes).
|| [ -n "$line" ]The last line is kept even without a final newline.
${line%$'\r'}The trailing \r of a Windows file is removed.
[ -z "$line" ] + continueEmpty lines, including those at the end, are skipped.
done < "$file"The loop reads the file. Unlike cat file | while …, a counter or array changed in the loop keeps its value after the loop – a pipe would run the loop in a subshell.

Three common variations:

  • Fields: while IFS=';' read -r name points || [ -n "$name" ]. The \r is now in the last field, so strip it there: points=${points%$'\r'}.
  • Numbers: number=$(echo "$line" | tr -d '[:space:]') removes spaces, tabs and the \r in one step.
  • Output file: > "$output_file" before the loop, echo "…" >> "$output_file" inside.

And the checks that come before the loop in every task of this session:

if [ "$#" -ne 1 ]; then
    echo "Usage: $0 <file>" >&2
    exit 1
fi
if [ ! -f "$1" ]; then
    echo "Error: file '$1' does not exist" >&2
    exit 1
fi
LF
\n, line feed – the line ending on Linux and macOS.
CR
\r, carriage return – on Windows it comes before every LF.
CRLF
\r\n, the Windows line ending.
truncate
Empty a file to 0 bytes – what > does before writing.
here string
<<< "$var" – one string as a command's input.

Practice

For every task: check the number of arguments and that the input file exists, write error messages to standard error and stop with exit 1. Tasks 1–5 are general scripting warm-ups; 6–9 read and write files and include the traps on purpose. Run your script on every numbers_*.txt file, and compare with cat -et.

Task 1

Factorial

Compute n! for a number given as an argument. Refuse numbers above 20.

$ ./factorial.sh 5
5! = 120
$ ./factorial.sh 21
Error: the number must be at most 20
Approach

Start the product at 1, so 0! is right without a special case. Bash computes with 64-bit integers: 21! no longer fits and silently overflows – that is why the limit is 20.

result=1                            # 0! = 1 falls out for free
for (( i=2; i<=n; i++ )); do
    result=$(( result * i ))
done

Task 2

Sorted words

Read words until the word end is typed, then print them sorted. It must also work when the input comes from a file (./sorted_words.sh < words.txt) that has no end and no newline after its last word.

Approach

Collect the words in an array, stop on end with break – the loop also stops at the end of the file. Thanks to || [ -n "$word" ] the unterminated last word isn't lost.

while read -r word || [ -n "$word" ]; do
    [ "$word" == "end" ] && break
    words+=("$word")
done
printf '%s\n' "${words[@]}" | sort

Task 3

Add up the arguments

Add up at most 10 integers given as arguments and print the sum. With --help instead of numbers, print a short usage text.

$ ./sum.sh 4 -2 10
Sum: 12
$ ./sum.sh 1 x
Error: 'x' is not an integer
Approach

Check --help before the argument count. Help that was asked for is not an error: standard output and exit 0. Then check "$#" is between 1 and 10.

if [ "$1" == "--help" ]; then        # before the argument count check
    echo "Usage: $0 <n1> [n2 ... n10]"
    exit 0                            # help that was asked for is not an error
fi
for n in "$@"; do
    [[ $n =~ ^-?[0-9]+$ ]] || { echo "Error: '$n' is not an integer" >&2; exit 1; }
    sum=$(( sum + n ))
done

Task 4

Odd and even lines

Split the file given as an argument into odd_lines.txt and even_lines.txt by line number. The test file's last line has no newline – it must still end up in odd_lines.txt.

Approach

Count lines yourself; empty both outputs once before the loop.

> odd_lines.txt; > even_lines.txt
while IFS= read -r line || [ -n "$line" ]; do
    n=$(( n + 1 ))
    if (( n % 2 == 1 )); then echo "$line" >> odd_lines.txt
    else                      echo "$line" >> even_lines.txt; fi
done < "$file"

Task 5

Phone registry

A registry file stores Name,number lines. Write a script with options: -a <name> <number> adds an entry (no duplicate names), -d <name> deletes one, -s <pattern> searches names case-insensitively and prints their numbers, -c prints the number of entries, -o lists the names sorted, each once. The file's last line has no newline.

$ ./registry.sh -a "Dan Szabo" 06301112222
Added: Dan Szabo,06301112222
$ ./registry.sh -s szab
Dan Szabo: 06301112222
Approach

A case on $1 calls one function per option. ^$name, matches the name exactly at the start of the line. Before -a appends, add the missing newline (Fig. 4). Count with grep -c '', not wc -l.

grep -q "^$name," "$registry" && { echo "Error: '$name' exists" >&2; exit 1; }

grep -v "^$name," "$registry" > "$registry.tmp"     # delete = keep everything else
mv "$registry.tmp" "$registry"

grep -c '' "$registry"                              # count, not wc -l
cut -d ',' -f 1 "$registry" | sort -u               # names, sorted, once each

Task 6

What is at the end of the file?

For a file given as an argument, print: the line count according to wc -l and according to a read loop, whether it ends with a newline, how many empty lines it has, and how many lines end in \r\n. Run it on all four numbers_*.txt files.

File:                 numbers_no_newline.txt
Lines (wc -l):        2
Lines (read loop):    3
Ends with newline:    NO
Empty lines:          0
Lines ending in CRLF: 0
Approach

Count CRLF lines before stripping the \r; count empty lines after. The final-newline test is the tail -c 1 trick.

if [[ $line == *$'\r' ]]; then          # the line still ends in \r
    crlf=$(( crlf + 1 ))
fi
[ -z "$(tail -c 1 "$file")" ] && ends="yes" || ends="NO"

Task 7

Sum numbers from file to file

Read one integer per line from an input file. Copy the valid numbers to an output file and append Sum: <total> at the end. Skip empty lines; for invalid lines print a warning with the line number. It must give the right result on every numbers_*.txt file, including numbers_mixed.txt, which has every trap at once – spaces, tabs, CRLF, empty and invalid lines.

$ ./sum_numbers.sh numbers_mixed.txt result.txt
Warning: line 6 is not an integer, skipped: 'abc'
Warning: line 8 is not an integer, skipped: '1.5'
Sum: 133
Approach

Refuse the same file as input and output (-ef). Clean each line with tr -d '[:space:]' – that handles spaces, tabs and \r together – then validate with a regex. Keep your own line counter for the warnings.

number=$(echo "$line" | tr -d '[:space:]')   # spaces, tabs and \r in one go
[ -z "$number" ] && continue
if [[ ! $number =~ ^-?[0-9]+$ ]]; then
    echo "Warning: line $line_number is not an integer, skipped: '$number'" >&2
    continue
fi

Task 8

Clean up a text file

From an input file, produce a clean output file: no \r, no spaces at the start or end of lines, no empty lines, and a newline after the last line. Check the result with cat -et.

$ cat -et clean.txt
First line$
Second line, had leading spaces$
Fourth line, had an empty line before$
Fifth line$
Approach

Delete the \r with tr first. Then use read -r line without IFS= – this time trimming is what you want. echo writes a newline after every line, the last one included. One redirection after done writes the whole loop's output.

tr -d '\r' < "$input_file" | while read -r line || [ -n "$line" ]; do
    [ -n "$line" ] && echo "$line"     # read without IFS= trims both ends
done > "$output_file"

Task 9

Grades report

grades.txt starts with a header line, then name;points lines – with CRLF line endings, empty lines, an invalid score and no newline after the last line. Write a report file with each student's name, points and grade, then the number of students, the average and the best student. Grade limits: 0–49: 1, 50–59: 2, 60–69: 3, 70–84: 4, 85–100: 5.

name;points;grade
Anna Kiss;87;5
Bela Nagy;45;1
Carol Toth;92;5
…
---
Students: 7
Average: 64
Best: Carol Toth (92)
Approach

The \r ends up in points, the last field – without cleaning it, no score is a number. Grade with an if/elif chain from the top down (-ge 85, -ge 70 …). Before dividing for the average, check the count isn't 0.

while IFS=';' read -r name points || [ -n "$name" ]; do
    line_number=$(( line_number + 1 ))
    [ "$line_number" -eq 1 ] && continue                  # header
    points=$(echo "$points" | tr -d '[:space:]')          # the \r sits in the LAST field
    ...
done < "$input_file"

More practice

Every task reads from a file and writes to a file, and the input files contain this session's traps. Recreate the output file on every run – running twice must give the same result.

Task 10

Number the lines

Write the lines of poem.txt to a new file as <number>: <line>. Empty lines stay empty and get no number. The last line has no newline; it must still appear, numbered 5.

Approach

Here an empty line is not skipped with continue alone – write an empty line first.

if [ -z "$line" ]; then
    echo >> "$output_file"      # keep the empty line, without a number
    continue
fi
number=$(( number + 1 ))
echo "$number: $line" >> "$output_file"

Task 11

Inventory value

inventory.txt has a header line, then product;quantity;unit price lines (CRLF, empty lines). Write each product and its value (quantity × price) to inventory_report.txt, then the total. Invalid lines go to errors.txt with their line number. Expected: total 18840, 2 invalid lines.

Approach

Split with IFS=';', skip line 1, clean both numbers, validate both.

while IFS=';' read -r product quantity price || [ -n "$product" ]; do
    quantity=$(echo "$quantity" | tr -d '[:space:]')
    price=$(echo "$price" | tr -d '[:space:]')
    if [[ ! $quantity =~ ^[0-9]+$ ]] || [[ ! $price =~ ^[0-9]+$ ]]; then
        echo "line $line_number: $product" >> errors.txt
        continue
    fi

Task 12

Split a log file

Lines of server.log look like date time LEVEL message. Sort them into info.log, warning.log and error.log, and print how many lines each level had. Running it twice must give the same files. Expected: 5 INFO, 2 WARNING, 2 ERROR – the last line has no newline.

Approach

Empty all three outputs before the loop. A here string splits the line at spaces; the message, with its spaces, goes into the last variable.

read -r date time level message <<< "$line"
case "$level" in
    INFO)    echo "$line" >> info.log ;;
    WARNING) echo "$line" >> warning.log ;;
    ERROR)   echo "$line" >> error.log ;;
esac

Task 13

Merge two files

Line i of names.txt belongs to line i of emails.txt. Write them to contacts.txt as name;email. If one file is shorter, the missing field stays empty (Emese Szabo;).

Approach

Read each file into its own array (with the universal pattern), then loop with an index up to the longer length. A missing array element expands to an empty string.

count=${#names[@]}
[ "${#emails[@]}" -gt "$count" ] && count=${#emails[@]}
for (( i=0; i<count; i++ )); do
    echo "${names[i]};${emails[i]}" >> "$output_file"   # a missing element is just ""
done

Task 14

Extend the phone registry

Add two options to task 5: -e <name> <new number> changes a number, -i <file> imports entries from a file. On import, trim spaces and \r, skip invalid lines and names already in the registry. Expected with new_contacts.txt: 2 new names, 1 already present, 1 invalid.

Approach

Editing = rewrite every line to a temporary file, the matching one changed, then mv. During import, a bad line is continue, not exit – one bad line must not stop the whole import.

while IFS=, read -r current_name current_phone || [ -n "$current_name" ]; do
    if [ "$current_name" == "$name" ]; then echo "$name,$phone"
    else echo "$current_name,$current_phone"; fi
done < "$registry" > "$registry.tmp"
mv "$registry.tmp" "$registry"

Common mistakes

WrongRightWhy
while read -r linewhile IFS= read -r line || [ -n "$line" ]Loses an unterminated last line.
for line in $(cat file)while IFS= read -r line …; done < filefor splits into words, not lines.
wc -l as the line countgrep -c '' file or count in the loopwc -l counts newline characters.
echo "$new" >> filefirst [ -n "$(tail -c 1 file)" ] && echo >> fileThe new line can glue onto the last one.
regex on a Windows filefirst line=${line%$'\r'}The invisible \r is at the end of the line.
>> in the loop, no emptying> "$out" before the loopThe second run appends to the first.
cmd < f.txt > f.txttemporary file, then mv> empties the file before it is read.
avg=$(( sum / count ))first if [ "$count" -eq 0 ]An empty file divides by zero.

Summary

  • > empties then writes, >> appends. Empty the output once before the loop, append inside it.
  • A line ends with \n. Windows adds a \r before it; some files have no \n after the last line.
  • cat -et shows the truth: $ = \n, ^M = \r, no $ at the end = no final newline.
  • Start every file loop from the universal pattern: || [ -n "$line" ], strip \r, skip empty lines.
  • Test on a normal file, an unterminated one and a CRLF one – on the Linux server, not only in Git Bash.