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.
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\nas 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.
| Operator | Meaning |
|---|---|
cmd < file.txt | Input comes from the file. |
cmd > file.txt | Output goes to the file. The old content is deleted first (a missing file is created). |
cmd >> file.txt | Output is appended to the end of the file; the old content stays. |
> file.txt | On its own: empty the file, or create an empty one. |
cmd << EOF … EOF | A multi-line block written in the script is the input (here document). |
cmd <<< "text" | One string or variable is the input (here string). |
cmd >&2 | Output goes to standard error – use it for error messages. |
> 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"| Part | What it does |
|---|---|
done < "$file" | The whole loop reads from the file; each read takes the next line. |
-r | A 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.txtWhat is a line?
In a text file a line is ended by a newline character. Which character that is depends on the system:
| System | Line ending | Written as |
|---|---|---|
| Linux, macOS | LF (line feed) | \n |
| Windows | CR 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).
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 bytecat -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:
| Symptom | Fix |
|---|---|
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".
|| 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 linesThe 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.
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
fiDo 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).
\r rides along in the variable until you strip it.| What you do | What 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 points | The \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 directoryWhere 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.
| Fix | Effect |
|---|---|
${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.txt | Deletes 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.txt | The 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 installedTry 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"| File | Naive: lines | Naive: sum | Safe pattern: sum |
|---|---|---|---|
numbers_ok.txt | 3 | 60 | 60 |
numbers_no_newline.txt | 2 | 30 | 60 |
numbers_empty_lines.txt | 5 | 60 | 60 |
numbers_crlf.txt | 3 | error | 60 |
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"| Part | What it solves |
|---|---|
IFS= read -r line | The 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" ] + continue | Empty 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\ris now in the last field, so strip it there:points=${points%$'\r'}. - Numbers:
number=$(echo "$line" | tr -d '[:space:]')removes spaces, tabs and the\rin 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 20Approach
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 ))
doneTask 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[@]}" | sortTask 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 integerApproach
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 ))
doneTask 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: 06301112222Approach
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 eachTask 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: 0Approach
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: 133Approach
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
fiTask 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
fiTask 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 ;;
esacTask 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 ""
doneTask 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
| Wrong | Right | Why |
|---|---|---|
while read -r line | while IFS= read -r line || [ -n "$line" ] | Loses an unterminated last line. |
for line in $(cat file) | while IFS= read -r line …; done < file | for splits into words, not lines. |
wc -l as the line count | grep -c '' file or count in the loop | wc -l counts newline characters. |
echo "$new" >> file | first [ -n "$(tail -c 1 file)" ] && echo >> file | The new line can glue onto the last one. |
| regex on a Windows file | first line=${line%$'\r'} | The invisible \r is at the end of the line. |
>> in the loop, no emptying | > "$out" before the loop | The second run appends to the first. |
cmd < f.txt > f.txt | temporary 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\rbefore it; some files have no\nafter the last line. cat -etshows 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.