Table of Contents
Overview
Advanced shell scripts benefit from being structured, readable, and maintainable. This chapter focuses on how to organize a script: the typical sections, recommended ordering, and patterns for larger scripts. The examples use bash, but the ideas apply to other shells.
Basic Script Skeleton
A common, clear structure for a non-trivial script is:
- Shebang and shell options
- Metadata and usage (comments, help text)
- Global constants and defaults
- Environment and dependency checks
- Functions
- Main logic (often in a
mainfunction) - Script entry point
A minimal example:
#!/usr/bin/env bash
set -euo pipefail
#================================================================
# myscript.sh - Short description of what this script does
#----------------------------------------------------------------
# Author: Your Name
# License: MIT
#================================================================
#------------------------- Configuration ------------------------
SCRIPT_NAME=${0##*/}
VERBOSE=false
#------------------------ Helper Functions ----------------------
log() {
printf '%s\n' "$*" >&2
}
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [options] arg1 arg2
Options:
-v Enable verbose output
-h Show this help and exit
EOF
}
#------------------------ Main Functions ------------------------
main() {
parse_args "$@"
do_work
}
parse_args() {
:
}
do_work() {
:
}
#----------------------- Script Entry Point ---------------------
main "$@"The sections and their layout are more important than the exact names; consistency matters most.
Shebang and Shell Options
The first line should specify the interpreter:
- Portable style (recommended for
bash):
#!/usr/bin/env bash- Direct path (common on many systems):
#!/bin/bashImmediately after the shebang, place shell options that control error handling and behavior. A common pattern for robust scripts:
set -euo pipefail
IFS=$'\n\t'set -e– exit on non-zero status (with caveats).set -u– treat use of unset variables as errors.set -o pipefail– a pipeline fails if any element fails.- Setting
IFSto newline and tab avoids word-splitting surprises.
For complex scripts, you might centralize options in one place instead of scattering set +e / set -e toggles throughout.
Header Comments and Metadata
At the top, before logic, add a header block with key information:
- Script name and short description
- Author/maintainer
- License or usage rights
- Version (if you plan to maintain it)
- Brief notes about requirements
Example template:
#================================================================
# backup-db.sh - Backup database to compressed file
#----------------------------------------------------------------
# Author: Your Name <you@example.com>
# Version: 1.2.0
# Requirements: bash 4+, pg_dump, gzip
#================================================================This metadata does not change behavior, but it is part of a good structure and helps future maintenance.
Constants, Defaults, and Config Section
Next, define constants and default configuration values in one place:
- Script name and version
- Default paths
- Default flags (like
VERBOSE=false) - Tunables that users or admins may want to override
Example:
#------------------------- Configuration ------------------------
SCRIPT_NAME=${0##*/}
SCRIPT_VERSION="1.2.0"
# Default settings (can be overridden by env or CLI)
BACKUP_DIR=${BACKUP_DIR:-/var/backups}
DB_HOST=${DB_HOST:-localhost}
RETENTION_DAYS=${RETENTION_DAYS:-7}
VERBOSE=${VERBOSE:-false}
DRY_RUN=${DRY_RUN:-false}Structural points:
- Group related variables with comments or blocks.
- Prefer uppercase names for “global” constants.
- Use
${VAR:-default}so environment variables can override defaults without editing the script.
If the script supports a config file (e.g. /etc/myscript.conf), this is where you would source it:
CONFIG_FILE=/etc/myscript.conf
[ -r "$CONFIG_FILE" ] && . "$CONFIG_FILE"Keep all configuration-related lines together near the top.
Environment and Dependency Checks
Before heavy logic, add a section to validate assumptions:
- Required external commands present (
command -v/type). - Required environment variables set.
- Minimum shell version (e.g. bash 4 for associative arrays).
- OS or distribution checks if needed.
Place this logic early so you fail fast.
Example:
#---------------------- Environment Checks ----------------------
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
printf 'Error: required command not found: %s\n' "$1" >&2
exit 1
}
}
check_requirements() {
require_cmd pg_dump
require_cmd gzip
# Require bash 4+
local bash_major=${BASH_VERSINFO[0]:-0}
if (( bash_major < 4 )); then
printf 'Error: bash 4 or newer is required.\n' >&2
exit 1
fi
}
Then call check_requirements from your main flow (e.g. within main).
Functions as Building Blocks
For advanced scripts, structure the bulk of logic as functions rather than inline commands:
- Group functions by purpose (parsing, I/O, business logic).
- Order from low-level “utility” helpers to high-level operations.
- Keep functions short and focused.
A common layout:
- Generic helper utilities (logging, error handling)
- Argument and configuration parsing
- Core functional units (e.g.
backup_database,cleanup_old_backups) - Orchestration (
mainand similar top-level functions)
Example structure:
#------------------------ Helper Functions ----------------------
log() {
printf '[%s] %s\n' "$SCRIPT_NAME" "$*" >&2
}
die() {
log "ERROR: $*"
exit 1
}
debug() {
$VERBOSE && log "DEBUG: $*"
}
#------------------- Argument Parsing Function ------------------
parse_args() {
while getopts ':o:hvd' opt; do
case "$opt" in
o) BACKUP_DIR=$OPTARG ;;
v) VERBOSE=true ;;
d) DRY_RUN=true ;;
h) usage; exit 0 ;;
\?) usage >&2; exit 2 ;;
:) printf 'Option -%s requires an argument.\n' "$OPTARG" >&2
usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
# Positional arguments
DB_NAME=${1:-}
[ -z "$DB_NAME" ] && die "Missing database name"
}
#----------------------- Core Functions -------------------------
backup_database() {
local db=$1
local ts
ts=$(date +%Y%m%d-%H%M%S)
local outfile="${BACKUP_DIR}/${db}-${ts}.sql.gz"
debug "Backing up $db to $outfile"
$DRY_RUN && return 0
mkdir -p "$BACKUP_DIR"
pg_dump -h "$DB_HOST" "$db" | gzip > "$outfile"
}
cleanup_old_backups() {
debug "Cleaning backups older than $RETENTION_DAYS days"
$DRY_RUN && return 0
find "$BACKUP_DIR" -type f -name '*.sql.gz' -mtime +$RETENTION_DAYS -delete
}Structurally, this keeps all behavior encapsulated and testable.
The `main` Function Pattern
For anything non-trivial, use a dedicated main function as the orchestration entry point. This avoids clutter at the bottom of the script and makes the flow easy to follow.
Example:
#------------------------ Main Function -------------------------
main() {
parse_args "$@"
check_requirements
backup_database "$DB_NAME"
cleanup_old_backups
}
#----------------------- Script Entry Point ---------------------
main "$@"Benefits of this pattern:
- You can later add pre/post hooks around
mainif needed. - Easier to unit-test functions in isolation.
- Maintains a single, clear entry point.
Logical Sections and Visual Separation
Readable structure relies on visual grouping. Common conventions:
- Use comment “rulers” to separate sections:
#==================== Section Title ====================
#------------------- Subsection Title ------------------- Group related functions together and mark them:
# Logging helpers
log() { ... }
die() { ... }
# Backup operations
backup_database() { ... }
cleanup_old_backups() { ... }- Keep global variable definitions in one section; avoid redefining them deep in the code.
Be consistent within your project: pick a style and stick to it.
Error Handling and Exit Strategy
Part of script structure is how you handle and propagate errors.
Common structural elements:
- A centralized
dieorfatalfunction for fatal errors. - A consistent convention for exit codes.
- Optional use of
trapto run cleanup code on exit or error.
Example:
#---------------------- Error Handling -------------------------
die() {
local code=${2:-1}
printf 'ERROR: %s\n' "$1" >&2
exit "$code"
}
cleanup() {
# Remove temp files, unlock resources, etc.
[ -n "${TMP_DIR:-}" ] && rm -rf "$TMP_DIR"
}
trap cleanup EXIT
Then, within your functions, call die "message" rather than sprinkling exit everywhere.
This makes the error-handling structure consistent and clear.
Using `trap` for Lifecycle Hooks
trap allows you to structure code around script lifecycle events:
EXIT– always run at script terminationERR– run when a command fails (withset -e)INT,TERM– handle interruptions and termination
Example structural pattern:
#-------------------- Lifecycle / Traps ------------------------
cleanup() {
debug "Cleaning up"
[ -n "${TMP_FILE:-}" ] && rm -f "$TMP_FILE"
}
on_error() {
local exit_code=$?
log "An error occurred (exit code $exit_code)"
}
trap cleanup EXIT
trap on_error ERR
trap 'log "Interrupted"; exit 130' INT
Place trap definitions near the top-level control flow (before main is called), so they apply to the whole script.
Logging and Verbosity Structure
Centralized logging helpers shape the structure of all output:
#----------------------------- Logging -------------------------
LOG_LEVEL=${LOG_LEVEL:-info}
_log_level_value() {
case "$1" in
debug) echo 10 ;;
info) echo 20 ;;
warn) echo 30 ;;
error) echo 40 ;;
*) echo 20 ;;
esac
}
log_msg() {
local level=$1; shift
local current=$(_log_level_value "$LOG_LEVEL")
local required=$(_log_level_value "$level")
if (( required >= current )); then
printf '[%s] [%s] %s\n' "$SCRIPT_NAME" "$level" "$*" >&2
fi
}
Then throughout the script use log_msg info "...", etc. Structurally, this keeps all messaging consistent and easy to adjust.
Modularization and Reusable Libraries
For larger projects, you may split common logic into separate files and source them:
lib/log.sh– logging helperslib/fs.sh– filesystem-related helperslib/net.sh– networking utilities
In your main script:
#----------------------- Library Includes ----------------------
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
. "$SCRIPT_DIR/lib/log.sh"
. "$SCRIPT_DIR/lib/fs.sh"Structurally, this keeps the main script focused on business logic, while reusable tools live in libraries.
Structuring Large Scripts
For very large scripts (hundreds of lines), an effective high-level structure is:
- Boilerplate / metadata
- Shebang,
setoptions, header comments - Config and constants
- Defaults, environment overrides, config file loading
- Libraries and includes
- Utilities
- Logging, error handling, small helpers
- Domain-specific functions
- Operations clearly related to the script's purpose
- Argument parsing and validation
- Main orchestration
mainand related- Entry point
main "$@"
Try to maintain a top-down readability: someone scanning the file from top to bottom should get an increasingly detailed understanding of what the script does, not a random jumble of logic.
Example: Putting It All Together
A compressed but complete pattern:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
#================================================================
# example-structured.sh - Demonstrate script structure
#================================================================
#------------------------- Configuration ------------------------
SCRIPT_NAME=${0##*/}
LOG_LEVEL=${LOG_LEVEL:-info}
#----------------------------- Logging -------------------------
_log_level_value() {
case "$1" in
debug) echo 10 ;;
info) echo 20 ;;
warn) echo 30 ;;
error) echo 40 ;;
*) echo 20 ;;
esac
}
log() {
local level=$1; shift
local current=$(_log_level_value "$LOG_LEVEL")
local required=$(_log_level_value "$level")
(( required >= current )) || return 0
printf '[%s] [%s] %s\n' "$SCRIPT_NAME" "$level" "$*" >&2
}
die() {
log error "$*"
exit 1
}
#----------------------------- Usage ---------------------------
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [-v] name
Options:
-v Verbose (debug logging)
-h Show this help
EOF
}
#----------------------- Argument Parsing ----------------------
parse_args() {
while getopts ':vh' opt; do
case "$opt" in
v) LOG_LEVEL=debug ;;
h) usage; exit 0 ;;
\?) usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
NAME=${1:-}
[ -z "$NAME" ] && die "Missing required NAME argument"
}
#----------------------- Core Functions ------------------------
greet() {
log debug "Preparing greeting for: $NAME"
printf 'Hello, %s!\n' "$NAME"
}
#------------------------ Main Function ------------------------
main() {
parse_args "$@"
greet
}
#--------------------- Script Entry Point ----------------------
main "$@"The logic is trivial, but the structure matches what you would use for a much more complex tool.
Summary
- Use a consistent sectioned layout: metadata, config, checks, utilities, main logic, entry point.
- Encapsulate behavior in functions, with
mainas a clear orchestrator. - Centralize cross-cutting concerns: logging, error handling, cleanup, configuration.
- For larger scripts, consider modularization via sourced library files.
- Focus on readability and maintainability: structure is what makes advanced scripts manageable over time.