CTCaliberTek
Course: Shell Scripting for Linux and Unix: From First Script to Automation · Lesson 6 of 8Log in to track your progress and earn a certificate when you finish the course.
Programming

Writing Safe Bash Scripts: set -euo pipefail, getopts, and trap

Published November 10, 2026·Updated November 10, 2026·11 min read

Edited by , Senior Data Analyst. How we make our content

Like SaveLog in to like, save, and track progress.

Introduction

A script that works on your laptop with perfect input is easy to write. A script that is safe to run on a production server at three in the morning is harder, because shell scripts continue after errors by default, treat unset variables as empty strings, and expand unquoted values in surprising ways. This lesson shows the habits that prevent the classic disasters: strict mode, argument validation, proper option parsing, guaranteed cleanup, careful deletion, and an automatic checker called ShellCheck.

Prerequisites

  • The earlier lessons on variables, conditionals, loops, and functions
  • Permission to install a small tool such as shellcheck, which is optional

Step-by-Step Guide

Step 1: Turn on strict mode

By default Bash keeps going after a command fails. Three settings change that. The -e option exits when a command returns an error, -u treats an unset variable as an error instead of silently using an empty string, and -o pipefail makes a pipeline fail when any command in it fails, not just the last. Strict mode is not perfect: commands in an if condition or before && and || are allowed to fail, and a few cases are still surprising. Treat it as a safety net rather than a replacement for checking important steps yourself. When a command is allowed to fail, say so explicitly with or true.

#!/usr/bin/env bash
set -euo pipefail
# -e          exit when a command fails
# -u          unset variables are errors
# -o pipefail a pipeline fails if any command in it fails

echo "Before"
grep -q "optional" settings.conf || true    # allowed to fail
false                                        # the script stops here
echo "Never printed"

Step 2: Validate arguments and print usage

Check that the script received what it needs before doing any work, and print a short usage message to standard error when it did not. The conventional exit status for incorrect usage is 2, which distinguishes it from a runtime failure with 1. Validating early means a typo does not run half a job before breaking, and the usage text documents the script for the next person, who is often you in six months.

usage() {
  echo "Usage: ${0##*/} <source_dir> <dest_dir>" >&2
  exit 2
}

[[ $# -eq 2 ]] || usage
src=$1
dest=$2
[[ -d $src ]] || { echo "Error: $src is not a directory" >&2; exit 1; }

Step 3: Parse options with getopts

The built-in getopts command handles options such as -v and -o file in a standard way, including combined flags like -vo. Put a colon at the start of the option string to handle errors yourself, and a colon after a letter when that option takes a value. Inside the loop, OPTARG holds the value. After the loop, shift by OPTIND minus one so that only the remaining positional arguments are left. Use an if statement instead of a bare and-list for the final checks, so a false flag cannot become the exit status of the script.

#!/usr/bin/env bash
set -euo pipefail

verbose=false
output="report.txt"

while getopts ":vo:h" opt; do
  case $opt in
    v) verbose=true ;;
    o) output=$OPTARG ;;
    h) echo "Usage: $0 [-v] [-o file] input"; exit 0 ;;
    :) echo "Option -$OPTARG needs a value" >&2; exit 2 ;;
    \?) echo "Unknown option -$OPTARG" >&2; exit 2 ;;
  esac
done
shift $(( OPTIND - 1 ))      # what is left are the positional arguments

[[ $# -ge 1 ]] || { echo "Missing input file" >&2; exit 2; }
if $verbose; then
  echo "Writing to $output"
fi
echo "Input: $1"

Step 4: Clean up with mktemp and trap

Scripts often need temporary files, and they should not leave them behind when something fails. The mktemp command creates a uniquely named file or directory safely. The trap command registers a function to run when the script ends, and the EXIT condition covers normal completion, errors under strict mode, and an interrupt with Ctrl+C. Define the trap right after creating the resource so there is no window where a failure could skip the cleanup.

tmp_dir=$(mktemp -d)
cleanup() {
  rm -rf "$tmp_dir"
}
trap cleanup EXIT          # runs on normal exit, on errors, and on Ctrl+C

echo "Working in $tmp_dir"
cp data.csv "$tmp_dir/"

Step 5: Check dependencies and report errors clearly

If your script needs another program, check that it exists at the start and fail with a clear message rather than halfway through. The command -v test is portable and does not print output. Small helper functions for logging and for fatal errors keep the main code readable, and sending errors to standard error keeps them out of any data your script prints for other programs to read.

require() {
  command -v "$1" > /dev/null 2>&1 || { echo "Error: $1 is required" >&2; exit 1; }
}
require jq
require curl

log() { echo "$(date +%T) $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }

config=${1:-app.conf}
[[ -r $config ]] || die "cannot read $config"
log "Using $config"

Step 6: Delete and loop over files safely

Most famous shell disasters involve rm. An unset or empty variable inside a path can turn a harmless command into one that targets the root of a drive. The colon-question expansion stops the script when the variable is empty, and a double dash tells commands that everything after it is a file name even if it starts with a hyphen. Do not parse the output of ls to get file names. Use wildcards for simple cases, and find with null separators when names may contain unusual characters.

# Dangerous: if dir is empty this becomes rm -rf /*
rm -rf $dir/*

# Safer: quote it, and stop if dir is unset or empty
rm -rf -- "${dir:?dir is not set}"/*

# Do not parse ls. Use a glob or find instead:
for f in ./*.csv; do
  [[ -e $f ]] || continue
  echo "$f"
done
find . -name "*.csv" -print0 | while IFS= read -r -d '' f; do
  echo "$f"
done

Step 7: Lint your scripts with ShellCheck

ShellCheck is a free static analysis tool that finds quoting mistakes, unused variables, risky constructs, and portability problems, and its messages explain why each one matters. Run it before you commit a script, and consider running it in your CI pipeline. Pair it with bash -n, which only checks that the syntax is valid and runs nothing. Both catch errors that would otherwise appear only when a particular branch runs in production.

sudo apt install shellcheck     # Debian and Ubuntu
brew install shellcheck         # macOS
shellcheck backup.sh
bash -n backup.sh               # syntax check only, nothing is executed

Step 8: Start from a safe template

Bringing the habits together gives a skeleton you can copy for new scripts: a strict-mode header, helper functions, a main function that holds the logic, and one call at the bottom that passes along the arguments. Putting the logic in main means the whole file is parsed before anything runs, which protects against a script being edited while it executes, and it makes the structure obvious to readers.

#!/usr/bin/env bash
# name.sh - one line describing what the script does
set -euo pipefail

readonly SCRIPT_NAME=${0##*/}

usage() { echo "Usage: $SCRIPT_NAME <arg>" >&2; exit 2; }
log()   { echo "$(date '+%F %T') $*"; }
die()   { echo "$(date '+%F %T') ERROR: $*" >&2; exit 1; }

main() {
  [[ $# -eq 1 ]] || usage
  log "Starting with $1"
  # ... the real work goes here ...
  log "Finished"
}

main "$@"

FAQs

Should every script use set -e?
It is a good default for scripts that run unattended, but know its limits. It does not trigger for commands inside conditions or and-or lists, and subshells and pipelines have special cases. For critical steps, still check the result explicitly and print a useful message.
Why does set -u complain about an empty array or optional variable?
With set -u, any reference to a variable that was never assigned is an error. Give optional values a default with the colon-dash expansion, such as ${name:-}, or assign an empty value at the top of the script. Expanding an empty array can also fail in older Bash versions before 4.4.
Is trap EXIT enough for cleanup?
It covers normal exits, failures under set -e, and most signals such as Ctrl+C. It cannot run if the process is killed with SIGKILL, or if the machine loses power, so keep temporary files in a temp directory that the system cleans periodically.

Summary

You learned how to make scripts fail loudly and safely with strict mode, validate arguments, parse options with getopts, clean up with trap and mktemp, check dependencies, avoid dangerous deletions, and lint with ShellCheck. The template at the end is a good starting point for new scripts. In the next lesson you will use everything so far to build a real backup script with rotation and logging.

Related tutorials

Comments

Log in or create a free account to join the discussion.

  • No comments yet. Be the first to comment.