← Back to all articles

Shell Scripting Essentials: Safe Preamble, Quoting Rules, Four Common Errors

CLIPitfalls

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

FormBehaviourGuidance
$varRe-split and glob-expandedAlmost never what you want
"$var"Passed through as one argumentThe default
${var}Clarifies boundaries, e.g. "${var}_suffix"Use when concatenating
'$(cmd)'Not executed inside single quotesWhen you need the literal text

Four places scripts break most

  1. Paths with spaces: unquoted, one filename splits into several arguments;
  2. cd failing then continuing: use cd /some/dir && do_something, never a bare cd line;
  3. rm with variables: rm -rf "$DIR"/* becomes dangerous when DIR is empty — check for emptiness and let set -u catch it;
  4. Pipelines swallowing errors: cmd | head can 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

  1. "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.
  2. "The script reported success but a step failed": a pipeline or subcommand error was swallowed. Fix: set -euo pipefail so failures surface immediately.
  3. "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.

  1. 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.
  2. Parse arguments in one place: handle and validate inputs and defaults at the top rather than scattering them through the file.
  3. Small single-purpose functions: one job per function with clear inputs and outputs, avoiding state passed through globals.
  4. Separate output streams: results to stdout, logs and errors to stderr, so piping and redirection behave correctly.
  5. Use exit codes: let callers distinguish failure reasons, and document each code in a header comment.
  6. 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".