Your First Scripts · about 45 min

Reading input and returning a status

Why this matters

Scripts talk with the outside world in both directions. In one direction they take input: from arguments (last lesson), from a person typing an answer, from a file, or from another program through a pipe. In the other direction, beside their output, they return an exit status that tells the caller (a person, another script, cron, a CI system) whether things worked. A script that always returns 0, even when it failed, will silently break every pipeline and automation built on top of it.

The read builtin

read reads one line from standard input and stores it in a variable:

$ read answer
yes please
$ echo "[$answer]"
[yes please]

The first command waited for you to type a line and press Enter. In a script:

#!/bin/bash
printf 'Your name? '
read -r name
echo "Hello, $name!"

(printf is used for the question because it does not add a newline, so the cursor stays on the same line.) read -p 'Your name? ' -r name does the same in one command: -p prints a prompt, but only when input comes from a terminal.

Underneath: standard input is just file descriptor 0

read does not know or care where its input comes from. It reads from file descriptor 0, which, as you learned in the redirection module, is whatever bash connected it to:

$ printf 'from a pipe\n' | bash greet.sh          # stdin is a pipe
$ bash greet.sh < names.txt                        # stdin is a file
$ bash greet.sh                                    # stdin is the terminal

All three work without changing the script. That is the Unix design paying off: a script that reads standard input can be used interactively and in pipelines.

Several variables at once

Give read several names, and it splits the line into fields using IFS, like word splitting: the first field goes into the first variable, and so on, and the last variable gets the whole rest of the line:

$ read first last <<< "Ada King Lovelace"
$ echo "first=$first last=$last"
first=Ada last=King Lovelace

(<<< is a here-string: it feeds a string as standard input. Module 15 covers it; here it just saves typing.) With a different IFS for this one command, you can split structured lines:

$ IFS=: read -r user pass uid gid rest < /etc/passwd
$ echo "$user has uid $uid"
root has uid 0

< /etc/passwd makes read read the first line of the file. The IFS=: prefix only applies to the read command, so the rest of the script keeps the normal IFS. An underscore _ is a conventional name for a field you do not care about: IFS=: read -r user _ uid _.

-r and IFS=: reading lines exactly

By default read does two kinds of processing that you usually do not want:

  1. Backslashes are escapes. a\tb becomes atb, and a backslash at the end of the line joins the next line on. -r (raw) turns that off.
  2. Leading and trailing whitespace is stripped (because space and tab are in IFS). Setting IFS= (empty) for the read command turns that off.
$ printf '  C:\\temp\\new  \n' > path.txt
$ read p < path.txt; echo "[$p]"
[C:tempnew]
$ read -r p < path.txt; echo "[$p]"
[C:\temp\new]
$ IFS= read -r p < path.txt; echo "[$p]"
[  C:\temp\new  ]

So the idiom for "read one line exactly as it is" is:

IFS= read -r line

Use -r always, unless you have a specific reason not to; shell linters warn about every read without it.

When read fails

read returns status 0 when it read a line, and non-zero at end of input (nothing left to read), or if the redirection fails because the file cannot be opened. One subtle case: if the last line of a file has no trailing newline, read still fills the variable but returns 1. You will handle that when reading files line by line with loops in module 10.

$ read -r x < /nonexistent
bash: /nonexistent: No such file or directory
$ echo $?
1

That makes read ... || exit 2 a natural way to stop when input is missing.

The pipeline trap

$ echo hello | read word
$ echo "[$word]"
[]

Every command in a pipeline runs in its own process (the pipes module showed why: they run at the same time). So read ran in a forked subshell and set word there. To get input into a variable of the current shell, use a redirection (< file), a here-string (<<<), or command substitution (word=$(echo hello)).

The exit status of a script

A script returns:

#!/bin/bash
# exists.sh FILE: is the file there?
ls "$1" > /dev/null 2>&1 && echo "yes" || echo "no"

This script always returns 0, because its last command is an echo that succeeds, whatever happened. Callers cannot use it with &&. Compare:

#!/bin/bash
# exists.sh FILE: exit 0 if the file exists, 1 if not
ls "$1" > /dev/null 2>&1 || exit 1
echo "yes"

Now ./exists.sh notes.txt && echo "go on" works as expected.

exit with no number uses the status of the last command. Choose meaningful numbers and stick to the common conventions:

StatusUse it for
0success
1a general failure (the work could not be done)
2wrong usage: missing or bad arguments
3-125your own specific failure kinds, documented in the script's usage text

Avoid 126 and above: they mean "not executable", "not found" and "killed by a signal", and using them confuses callers.

exit ends the process. In a script run as a program that is the script. If you source a file that calls exit, it ends your interactive shell, which is why sourced files use return (module 11) instead.

Putting it together

#!/bin/bash
# firstword.sh FILE: print the first word of the first line of FILE
read -r word _ < "$1" || exit 1
echo "$word"
$ ./firstword.sh /etc/os-release
PRETTY_NAME="Debian
$ ./firstword.sh /missing; echo "status $?"
./firstword.sh: line 3: /missing: No such file or directory
status 1

Common mistakes

Exercises

The code exercises feed input through a pipe and check exit statuses. Try them in the shell with printf ' Ada \\o/ \n' | bash script.sh and bash script.sh nope.txt; echo $?.

Recap

Going further: run help read and look at -n, -t and -s (read N characters, time out, silent input for passwords). Try read -s -p 'Password: ' pw; echo; echo ${#pw}.

Exercises

Exercise 1 · quiz

The read that vanished

What does this print in bash (with default settings)?

echo hello | read word
echo "[$word]"
Answer
  • []

[]. Each part of a pipeline runs in its own process, so read runs in a forked subshell. It reads hello into word perfectly well, in that process, which then exits. Your shell's word was never set. Read from a redirection instead (read word < file), or use a here-string (read word <<< "hello", module 15). The same subshell rule explains why cd in a pipeline has no lasting effect.

Exercise 2 · write code

Read a name

Write a script that reads one line from standard input and prints Hello, LINE!.

The line must be kept exactly as given: leading and trailing spaces and backslashes included. For the input Ada \o/ (two spaces before, two after) the output is:

Hello,   Ada \o/  !

Do not print a prompt (the checker supplies the input through a pipe).

File: script.sh

Starting point

read name
echo "Hello, $name!"

Example

input

Ada

expected output

Hello, Ada!

Example

input

  Ada \o/

expected output

Hello,   Ada \o/  !
Hint 1

Plain read strips leading and trailing whitespace (because of IFS) and treats backslashes as escapes.

Hint 2

IFS= read -r name turns both behaviors off: empty IFS for this one command, and -r for raw backslashes.

Reference solution
IFS= read -r name
echo "Hello, $name!"

Exercise 3 · write code

First line, or a failure status

Write a script that takes a file name as its first argument, reads the first line of that file with read (redirecting the file into read, not using head), and prints:

first line: TEXT

If the file does not exist or cannot be read, the script must print nothing on standard output and exit with status 2. (An error message on standard error is fine; the one bash prints by itself is enough.) Use || and exit.

The working directory contains poem.txt (first line: The fog comes) and two words.txt (first line: indented line, which must keep its leading spaces).

File: script.sh

Starting point

first=$(head -n 1 $1)
echo "first line: $first"

Example with arguments poem.txt

expected output

first line: The fog comes

Example with arguments two words.txt

expected output

first line:   indented line
Hint 1

read -r first < "$1" reads one line from the file. If bash cannot open the file, the whole command fails with status 1.

Hint 2

IFS= read -r first < "$1" || exit 2 stops the script with status 2 on failure.

Reference solution
IFS= read -r first < "$1" || exit 2
echo "first line: $first"

Exercise 4 · quiz

What status does the script return?

check.sh contains these three lines and nothing else:

grep -q root /etc/passwd && echo found
ls /nonexistent
echo done

After bash check.sh, what is $??

Type a number.

Answer
  1. Without an explicit exit, a script returns the status of the last command it ran,

here echo done, which succeeded. The failure of ls in the middle is reported on screen and then forgotten. That is often not what you want; the next lesson uses || exit 1 after important commands, and module 12 shows set -e, which makes a script stop at the first failure.