Your First Scripts · about 45 min

Writing scripts people can use

Why this matters

The person who will run your script in six months may be a colleague, a cron job, or you, having forgotten everything. A usable script says what it does, refuses bad input with a clear message instead of doing something destructive, keeps its messages out of its data, and reports failure through its exit status. None of this needs advanced features; it is a set of habits. This lesson collects them into a template you can start every script from.

A template

#!/bin/bash
#
# backup-dir: copy a directory into a dated tar archive.
#
# usage: backup-dir SOURCE [DEST]
#   SOURCE  directory to back up
#   DEST    where to put the archive (default: $BACKUP_DEST or ./backups)
#
# exit status: 0 ok, 1 backup failed, 2 bad usage

usage() { echo "usage: backup-dir SOURCE [DEST]" >&2; exit 2; }   # (functions: module 11)

[ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage
src=$1
dest=${2:-${BACKUP_DEST:-./backups}}
readonly stamp=$(date +%Y%m%d-%H%M%S)

[ -d "$src" ] || { echo "backup-dir: '$src' is not a directory" >&2; exit 1; }
mkdir -p "$dest" || exit 1

archive="$dest/$(basename "$src")-$stamp.tar.gz"
tar -czf "$archive" -C "$(dirname "$src")" "$(basename "$src")" || exit 1
echo "$archive"

Each part has a job. The rest of the lesson goes through them.

The header comment

The first lines after the shebang say what the script does, how to call it, and what its exit statuses mean. That is the minimum a caller needs. Write it before the code; if you cannot describe the script in two lines, it probably does too much. Inside the script, comment why, not what: # tar -C so the archive holds relative paths is useful, # run tar is noise.

Messages to standard error: >&2

Recall from the redirection module: file descriptor 1 is standard output, 2 is standard error. >&2 means "send this command's standard output to wherever fd 2 goes":

echo "backup-dir: '$src' is not a directory" >&2

The rule is simple: results go to standard output; everything meant for a human (errors, warnings, usage, progress) goes to standard error. Then:

$ ./backup-dir /nope > archive-name.txt
backup-dir: '/nope' is not a directory

The message still appears on the terminal, and archive-name.txt stays clean (empty) instead of containing an error message that the next script would try to use as a file name.

Start messages with the script's name, as the standard tools do (ls: cannot access ...). When your script is one step in a long pipeline or cron job, the name tells you which step complained.

Checking arguments: a first look at test

The template uses [ "$#" -ge 1 ]. [ is a command (you saw type [ in the first module), another name for the builtin test. It evaluates a condition and returns status 0 if it is true, 1 if it is false. It prints nothing. That is all you need to use it with && and || today:

TestTrue when
[ "$#" -eq 1 ]there is exactly one argument (-ne, -lt, -le, -gt, -ge also exist)
[ -z "$x" ]$x is empty
[ -n "$x" ]$x is not empty
[ -e "$f" ]file exists; -f regular file; -d directory
[ -r "$f" ]file is readable by you; -w writable; -x executable

The spaces matter: [ is a command name and ] is its last argument, so ["$#" -eq 1] is command not found. The conditionals module covers test, [[ ]] and if properly.

Grouping: { ...; }

To run two commands when a test fails, a message and an exit, group them with braces:

[ -r "$file" ] || { echo "wordcount: cannot read $file" >&2; exit 1; }

{ list; } runs the commands in the current shell as one unit. The syntax is strict: a space after {, and a ; (or newline) before }. (Do not confuse it with ( list ), which runs the list in a subshell, where an exit would only leave the subshell. Module 13 explains.)

The usage message and status 2

When a script is called wrongly, it should do nothing except print how to call it, to standard error, and exit with status 2. Many standard tools use 2 for misuse, and callers can tell "you called me wrong" apart from "I tried and failed". Mimic the SYNOPSIS notation from the man pages: usage: greet [NAME], usage: backup-dir SOURCE [DEST].

Variables at the top, lowercase, quoted

Collect settings at the top, with defaults that the environment can override:

dest=${2:-${BACKUP_DEST:-./backups}}
keep=${KEEP:-7}

Use lowercase names for script variables (uppercase is for environment and bash's own), and quote every expansion. readonly name=value marks a variable as constant: any later assignment fails with bash: stamp: readonly variable, which catches typos and accidental reuse.

printf versus echo

echo has portability quirks: some versions interpret backslashes, and echo "$var" misbehaves if $var is -n or -e (it is taken as an option). printf is predictable everywhere:

printf '%s\n' "$value"                 # print any value safely
printf '%s: %d words\n' "$file" "$n"   # %d formats an integer
printf '%-10s|%5s|\n' left right       # widths: left-aligned 10, right-aligned 5

Use echo for fixed messages if you like; prefer printf '%s\n' for printing data.

Fail early, fail loudly

Check everything you can before doing anything irreversible: arguments, that input files exist, that the destination is writable. Then after each important step, stop if it failed:

mkdir -p "$dest" || exit 1
tar -czf "$archive" ... || exit 1

The failing command has already printed its own error to stderr, so a bare exit 1 is enough. Module 12 shows set -e and traps, which automate part of this, along with their pitfalls.

Naming and installing

Common mistakes

Exercises

The code exercise is checked for its output, its exit statuses, and which stream its messages go to. The task's check runs your command directly from /work/bin.

Recap

Going further: pick a script from /usr/bin that is short (grep -l '^#!/bin/sh' /usr/bin/* and wc -l them) and read it. Notice how it handles usage and errors, and what you would improve.

Exercises

Exercise 1 · quiz

Why standard error?

Why should a script print its error and usage messages to standard error (>&2) rather than standard output? Select all that apply.

Answer
  • So that ./script.sh > result.txt does not hide the error inside result.txt.

  • So that a pipeline like ./script.sh | sort does not feed the error into sort as if it were data.

  • So the user still sees the message on the terminal when standard output is redirected.

Standard output is for the script's results, the data a caller may redirect or pipe. Standard error is for messages to the human. Keeping them apart means redirection and pipes carry only data while problems stay visible. Writing to stderr does not change the exit status at all; that is what exit 1 or exit 2 is for.

Exercise 2 · write code

A proper little tool

Write wordcount: it takes exactly one argument, a file name, and prints

FILE: N words

where N is the number of words in the file (wc -w < file). Requirements:

  1. With no argument, or with more than one, print usage: wordcount FILE to standard error, print nothing to standard output, and exit with status 2.
  2. If the file cannot be read, print wordcount: cannot read FILE to standard error and exit with status 1. (Check readability with [ -r "$1" ], which succeeds if the file exists and is readable.)
  3. Otherwise print the result line and exit 0.

For the argument check, [ "$#" -eq 1 ] succeeds when there is exactly one argument. The working directory contains poem.txt (9 words) and my notes.txt (5 words).

File: script.sh

Starting point

#!/bin/bash
# wordcount FILE: print how many words FILE contains
echo "$1: $(wc -w < $1) words"

Example with arguments poem.txt

expected output

poem.txt: 9 words

Example with arguments my notes.txt

expected output

my notes.txt: 5 words
Hint 1

Pattern: [ condition ] || { echo "message" >&2; exit N; }. The braces group the two commands so both run only when the test fails. Note the spaces inside { and the ; before }.

Hint 2

Quote $1 everywhere: one test file has a space in its name.

Reference solution
#!/bin/bash
# wordcount FILE: print how many words FILE contains
[ "$#" -eq 1 ] || { echo "usage: wordcount FILE" >&2; exit 2; }
file=$1
[ -r "$file" ] || { echo "wordcount: cannot read $file" >&2; exit 1; }
echo "$file: $(wc -w < "$file") words"

Exercise 3 · task in a shell

Install a command of your own

Make a real command:

  1. Create /work/bin/greet, an executable bash script with a #!/bin/bash shebang and a comment line describing it.
  2. It takes an optional name argument, defaulting to world, and prints Hello, NAME!. So greet prints Hello, world! and greet "Ada Lovelace" prints Hello, Ada Lovelace!.
  3. Given more than one argument it prints usage: greet [NAME] to standard error and exits with status 2.

Then try it by name: PATH="/work/bin:$PATH" followed by greet Ada. (The check runs the script directly; the PATH change only lasts for your shell session.)

Hint 1

[ "$#" -le 1 ] succeeds with zero or one argument. Combine it with || { echo ... >&2; exit 2; }.

Hint 2

name=${1:-world} then echo "Hello, $name!". Do not forget chmod +x bin/greet.

Reference solution
mkdir -p bin
cat > bin/greet <<'EOF'
#!/bin/bash
# greet [NAME]: print a greeting, to the world by default
[ "$#" -le 1 ] || { echo "usage: greet [NAME]" >&2; exit 2; }
name=${1:-world}
echo "Hello, $name!"
EOF
chmod +x bin/greet