Shell script

Source: turnoff.us
Shell scripts fail in the same handful of ways: a variable that was never set, a file name with a space in it, a pipeline that swallows an error, a temporary directory nobody cleaned up. What follows is what I put in a script to make those failures impossible, or at least loud.
Everything below is verified on GNU bash 5.3, and where macOS behaves differently it is called out.
Cookbook
Start every script the same way
#!/usr/bin/env bash
set -euo pipefail
-e stops the script at the first command that returns non-zero. -u turns the use of an unset
variable into an error instead of an empty string, which is what saves you from the classic
rm -rf "$PREFIX"/ with an empty PREFIX. -o pipefail makes a pipeline fail when any stage
fails, not just the last one.
#!/usr/bin/env bash rather than #!/bin/bash so the script uses the bash found in PATH. That
matters on macOS: /bin/bash is still 3.2.57, the last patch of a release from 2006, frozen
there because bash moved to GPLv3 with 4.0, a licence Apple does not ship. A bash 4 or 5 installed by Homebrew lives
somewhere else in PATH.
Without pipefail, a pipeline reports only the exit status of its last command:
set -e
false | true # exit status 0, the script keeps going
What strict mode does not catch
set -e is deliberately disabled everywhere the shell is already testing a status. None of these
stop the script, and that is by design:
if failing_command; then :; fi # tested, so the failure is expected
failing_command || fallback # same, the || tests it
failing_command && next # same
my_function || echo "handled" # -e is suppressed inside my_function too
The last one is the surprising one: as soon as a function’s result is tested by the caller,
set -e stops applying inside that function as well, so it runs to the end after a failing
command. When it matters, check explicitly rather than relying on -e.
Under -u, use an explicit default for anything that may legitimately be unset:
echo "${LOG_LEVEL:-info}"
Require an environment variable
Fail immediately, with a message, instead of running half the script with an empty value:
: "${API_TOKEN:?set API_TOKEN before running}"
The : is the do-nothing builtin, there only to give the expansion somewhere to live. If
API_TOKEN is unset or empty, bash prints API_TOKEN: set API_TOKEN before running and exits.
Find the directory of the running script
__DIR__=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
${BASH_SOURCE[0]} is the path of the file currently being read, dirname takes its directory,
and cd ... && pwd turns it into an absolute path.
Use ${BASH_SOURCE[0]} rather than $0: they agree when the script is executed, but when the
file is sourced, $0 belongs to whoever sourced it: the shell’s name from an interactive shell,
so dirname "$0" returns . and resolves to the caller’s current directory, or the calling
script’s path, so it resolves to that script’s directory. Either way, not the sourced file’s.
Verified: sourcing the same file from /tmp gives /tmp with $0 and the file’s own directory
with ${BASH_SOURCE[0]}.
Quote the inner command substitution: without the quotes, a path containing spaces is split into
several arguments and cd fails with “too many arguments”. The && makes the script stop instead
of falling back to a wrong directory if the cd fails.
A temporary directory that cleans itself up
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
The EXIT trap runs on a normal end, on an explicit exit, and on the abort caused by set -e,
so the directory is gone in all three cases. Verified: a script that creates files under $tmp
and then exits with code 3 still leaves nothing behind.
Single quotes around the trap matter. With double quotes the body is expanded when the trap is
installed, so a trap "rm -rf $tmp" EXIT written before tmp is assigned expands to
rm -rf and removes nothing (or worse, if the variable holds something else by then). Single
quotes defer the expansion to the moment the trap fires.
Loop over file names without breaking on spaces
find . -type f -print0 | while IFS= read -r -d '' f; do
echo "$f"
done
-print0 separates entries with a null byte, read -d '' reads up to that null byte, and IFS=
keeps leading and trailing whitespace. Apart from /, which paths need as a separator, null is the
only byte a file name cannot contain, which is what makes the pair safe.
The tempting version is broken:
for f in $(ls); do ... # "dir with space" becomes three iterations
Word splitting cuts on spaces, and a name containing a newline is split too. Verified: a directory
named dir with space yields dir, with, space.
Read a file line by line
while IFS= read -r line; do
echo "[$line]"
done < input.txt
-r stops read from treating backslashes as escapes, IFS= stops it from trimming leading and
trailing spaces. Without them, the line a\b comes back as ab.
If the file may lack a final newline, the last line is read but read returns non-zero and the
loop body is skipped. Guard it:
while IFS= read -r line || [ -n "$line" ]; do
The pipeline subshell trap
Each stage of a pipeline runs in a subshell, so anything assigned inside a while fed by a pipe
is lost when the pipeline ends:
n=0
printf 'a\nb\nc\n' | while read -r l; do n=$((n+1)); done
echo "$n" # 0, not 3
Feed the loop from a process substitution instead, which keeps it in the current shell:
n=0
while read -r l; do n=$((n+1)); done < <(printf 'a\nb\nc\n')
echo "$n" # 3
Both verified. (shopt -s lastpipe also fixes it, but only while job control is off, which is
the case in scripts and not in an ordinary interactive shell; it is off by default.)
Check whether a command is available
if ! command -v jq >/dev/null; then
echo "jq is required" >&2
exit 1
fi
command -v is a shell builtin and part of POSIX, so it is always there, and it also sees
functions, plus aliases wherever aliases are enabled (an interactive shell, or a script after
shopt -s expand_aliases). which is an external program that does not exist on every system and
whose exit status is not consistent across implementations.
Pass arguments through untouched
"$@" # each argument stays one argument
$@ # unquoted: every argument is re-split on spaces
Verified: with the arguments one two and three, the quoted form passes two arguments, the
unquoted form passes three.
Date aliases
alias isodate='date +%Y-%m-%dT%H:%M:%S%z'
Prints the current time in ISO 8601 with the timezone offset, for example
2026-06-15T08:14:27+0200. For the extended colon form +02:00, GNU date (Linux) accepts %:z;
the BSD date that ships with macOS does not, so keep %z there for a portable alias.
Checking your own scripts
ShellCheck finds most of the above statically, including the
unquoted expansions and the for f in $(ls) pattern. Worth wiring into CI for any repository that
carries shell scripts.
References
- Bash Reference Manual
- ShellCheck
- tmux, where these scripts usually end up running
- Git for the version control side of the same daily toolbox