Shell script

bashshellpath

Bash on Windows

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

Related

Keyboard shortcuts

/Open search
CtrlKOpen search (Cmd+K on macOS)
?Show this cheat sheet
Ctrl/Show this cheat sheet (Cmd+/ on macOS)
EscClose search or this cheat sheet

Turns off the single-key shortcuts (/, ?). Ctrl/Cmd+K and Ctrl/Cmd+/ stay.