Start with these four safety lines
#!/usr/bin/env bash
set -e # exit as soon as a command fails
set -u # error on undefined variables
set -o pipefail # a failing stage fails the whole pipeline
IFS=$'\n\t' # split only on newline and tab, so spaces are safe
Without them, scripts routinely "finish successfully" while a step in the middle failed silently.
Quoting: double-quote almost every variable
| Form | Behaviour | Guidance |
|---|---|---|
| $var | Re-split and glob-expanded | Almost never what you want |
| "$var" | Passed through as one argument | The default |
| ${var} | Clarifies boundaries, e.g. "${var}_suffix" | Use when concatenating |
| '$(cmd)' | Not executed inside single quotes | When you need the literal text |
Four places scripts break most
- Paths with spaces: unquoted, one filename splits into several arguments;
- cd failing then continuing: use
cd /some/dir && do_something, never a barecdline; - rm with variables:
rm -rf "$DIR"/*becomes dangerous when DIR is empty — check for emptiness and let set -u catch it; - Pipelines swallowing errors:
cmd | headcan exit 0 even when cmd failed — that is exactly why pipefail exists.
Habits for maintainable scripts
- Validate arguments first: print usage and exit when something is missing;
- Clean up with trap: release temp directories and locks on any exit;
- Timestamped, toggleable logging: helps debugging without polluting the caller's output;
- Offer --dry-run: print the commands first, then execute once you are confident.
set -euo pipefail
usage() { echo "usage: $0 <target-dir>"; exit 1; }
[ $# -eq 1 ] || usage
target=$1
[ -d "$target" ] || { echo "not a directory: $target" >&2; exit 1; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT # cleaned up on success or failure
Real-world cases: three production incidents
- "The script wiped a production directory": an empty variable plus no guard plus
rm -rf. Fix:set -u, guard before deleting, and add confirmation or --dry-run for dangerous steps. - "The script reported success but a step failed": a pipeline or subcommand error was swallowed. Fix:
set -euo pipefailso failures surface immediately. - "Paths with spaces broke it": unquoted variables were word-split. Fix: quote every variable reference as
"$var".
Common questions
Is sh different from bash? Yes — arrays, [[ ]] and pipefail are bash features. If you use them, declare #!/usr/bin/env bash and do not run the script as sh script.sh. Do scripts need tests? Anything over a few dozen lines used by more than one person deserves at least a syntax check and an empty-argument test. Command not found? Check for required commands explicitly rather than letting it fail halfway.
Organising maintainable scripts
Once a script grows from tens to hundreds of lines, maintainability collapses. These habits extend its life.
- Strict mode first: exit on command failure, undefined variables and any failing stage of a pipeline, so errors are not silently skipped. The best value for one line of configuration.
- Parse arguments in one place: handle and validate inputs and defaults at the top rather than scattering them through the file.
- Small single-purpose functions: one job per function with clear inputs and outputs, avoiding state passed through globals.
- Separate output streams: results to stdout, logs and errors to stderr, so piping and redirection behave correctly.
- Use exit codes: let callers distinguish failure reasons, and document each code in a header comment.
- Prefer idempotence: scripts should be rerunnable without side effects, especially for deployment and setup — rerunning to fix beats manual rollback.
When to switch languages
When a script starts handling complex data structures, needing unit tests or shipping cross-platform, the maintenance cost exceeds the benefit. Move to a language with types and packaging rather than growing the script further.
Testing scripts
Scripts deserve basic tests too: run them with fixed input and assert on output and exit code. Use a general-purpose test framework or a small assert helper. Even covering the main branches sharply reduces "fix one thing, break another".