Kahibaro
Discord Login Register

Script structure

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:

  1. Shebang and shell options
  2. Metadata and usage (comments, help text)
  3. Global constants and defaults
  4. Environment and dependency checks
  5. Functions
  6. Main logic (often in a main function)
  7. 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:

  #!/usr/bin/env bash
  #!/bin/bash

Immediately after the shebang, place shell options that control error handling and behavior. A common pattern for robust scripts:

set -euo pipefail
IFS=$'\n\t'

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:

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:

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:

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:

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:

A common layout:

  1. Generic helper utilities (logging, error handling)
  2. Argument and configuration parsing
  3. Core functional units (e.g. backup_database, cleanup_old_backups)
  4. Orchestration (main and 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:

Logical Sections and Visual Separation

Readable structure relies on visual grouping. Common conventions:

  #==================== Section Title ====================
  #------------------- Subsection Title ------------------
  # Logging helpers
  log() { ... }
  die() { ... }
  # Backup operations
  backup_database() { ... }
  cleanup_old_backups() { ... }

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:

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:

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:

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:

  1. Boilerplate / metadata
    • Shebang, set options, header comments
  2. Config and constants
    • Defaults, environment overrides, config file loading
  3. Libraries and includes
  4. Utilities
    • Logging, error handling, small helpers
  5. Domain-specific functions
    • Operations clearly related to the script's purpose
  6. Argument parsing and validation
  7. Main orchestration
    • main and related
  8. 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

Views: 24

Comments

Please login to add a comment.

Don't have an account? Register now!