Your First Script
A defensible bash script reads like a small program — shebang, strict mode, named functions, parameter parsing, traps for cleanup, and an entry point at the bottom. Adopting the conventions once means every future script is debuggable, robust against odd inputs, and survives review.
A template you can copy for any bash script
EXAMPLE
#!/usr/bin/env bash
# script-name.sh — one-line description
# Usage: ./script-name.sh [options] <required-arg>
# ===== 1) Strict mode — catch errors early =====
set -Eeuo pipefail
# -E ensure ERR trap is inherited
# -e exit on any error
# -u error on undefined variables
# -o pipefail a pipeline fails if ANY stage fails (not just the last)
IFS=$'\n\t' # word-splitting on newline + tab only (no spaces)
# ===== 2) Globals =====
PROG_NAME=$(basename "$0")
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
TMP="$(mktemp -d -t ${PROG_NAME%.sh}.XXXXXX)"
# ===== 3) Cleanup on any exit =====
cleanup() {
rm -rf "$TMP"
}
trap cleanup EXIT
trap 'echo "ERROR on line $LINENO. cmd: $BASH_COMMAND" >&2' ERR
# ===== 4) Help + usage =====
usage() {
cat <<USAGE
$PROG_NAME -- import a CSV into orders table
Usage:
$PROG_NAME [options] <path-to-csv>
Options:
-d, --dry-run do not write to the database
-e, --env <name> environment (dev|staging|prod) [default: dev]
-v, --verbose more output
-h, --help this help
USAGE
}
# ===== 5) Defaults + arg parsing =====
DRY_RUN=0
ENV=dev
VERBOSE=0
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--dry-run) DRY_RUN=1; shift ;;
-e|--env) ENV="${2:?missing env value}"; shift 2 ;;
-v|--verbose) VERBOSE=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) echo "unknown option: $1" >&2; usage; exit 2 ;;
*) break ;;
esac
done
CSV_PATH="${1:-}"
if [[ -z "$CSV_PATH" ]]; then
echo "missing CSV path" >&2; usage; exit 2
fi
# ===== 6) Helpers — keep them small and named =====
log() {
printf '[%s] %s\n' "$(date +'%Y-%m-%dT%H:%M:%S%z')" "$*" >&2
}
debug() {
(( VERBOSE )) && log "DEBUG: $*"
}
die() {
log "FATAL: $*"
exit 1
}
require() {
command -v "$1" >/dev/null 2>&1 || die "need '$1' on PATH"
}
confirm() {
read -r -p "$1 [y/N] " ans
[[ "${ans:-N}" =~ ^[Yy]$ ]]
}
# ===== 7) Main work =====
import_csv() {
local path="$1"
[[ -f "$path" ]] || die "CSV not found: $path"
log "importing $path into $ENV"
debug "using TMP=$TMP"
if (( DRY_RUN )); then
log "DRY RUN — would import $(wc -l <"$path") rows"
return 0
fi
# actual work goes here
while IFS=, read -r id customer total_cents; do
debug "row id=$id"
# ... call the DB, etc.
done < <(tail -n +2 "$path")
}
main() {
require psql
require jq
import_csv "$CSV_PATH"
log "done."
}
main "$@"
# ===== Patterns to internalise =====
# - set -Eeuo pipefail at the top of EVERY script
# - mktemp -d for any temp dir; clean up in EXIT trap
# - one main() function called at the bottom
# - small named helpers (log, debug, die, require)
# - manual --long-option parsing (avoid getopt) for portability
# - read -r when reading user input (preserve backslashes)
# - quote ALL variables: "$var"
# - use [[ ... ]] over [ ... ]
# - prefer command substitution $(cmd) over backticks
# - use 'local' inside functions for variables
# ===== Pitfalls =====
# - 'set -e' does NOT exit inside subshells in all cases; check explicitly
# - 'pipefail' is essential — without it, 'grep foo | wc' shows 0 even when grep fails
# - Bash arrays are 0-indexed; "${arr[@]}" expands each element quoted
# - Don't 'cd' without checking; use 'cd ... || die'
# - eval is almost always wrong; reach for arrays or here-docs instead
Why it matters
`set -Eeuo pipefail` + a trap on ERR + a single main() function is the trio that turns a bash script from "works on the laptop where I wrote it" into a small program you can hand to a colleague. Drop it into every script you write; the discipline pays for itself the first time a missing file would have silently produced wrong output instead of a clear error.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Exercise
Bash shebang line.
Starts with #!.
Discussion
Loading…