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:
| Test | True 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
- Choose names that do not collide with existing commands: check with
type namefirst. A script namedtestis a classic trap, sincetestis a builtin and wins. - Install personal commands into
~/binor~/.local/bin, add that toPATHin~/.bashrc, and drop the.sh.
Common mistakes
- Errors on standard output. They end up inside output files and pipelines.
- No usage check. The script runs with an empty
$1and does something unintended. exit 0(or no exit) after a failure. Callers think it worked.- Missing spaces in
[ ]or{ }.[$# -eq 1]gives[1: command not found;{echo hi; }gives{echo: command not found. - Using
( ... )to groupecho; exit. The exit leaves only the subshell, and the script carries on.
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
- Header comment: what it does, usage, exit statuses.
- Results to stdout; messages to stderr with
>&2, prefixed with the script's name. - Wrong usage: print
usage: ...to stderr, exit 2. Failure: exit 1. Success: 0. [ test ] || { echo "msg" >&2; exit N; }checks a condition;[ "$#" -eq 1 ],[ -r "$f" ].- Settings at the top with
${VAR:-default}; lowercase names; quote everything;readonly. printf '%s\n' "$var"is safer than echo for data.
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.