Skip to content
shellhacks.dev
Shell Scripting

How to Structure a Shell Script With main and Functions

Structure a shell script with a main function, small helpers and a guarded entry point, so it can be sourced for tests and read from the top down.

6 min read
Summarize in AI
Illustration for "How to Structure a Shell Script With main and Functions": a terminal session, a pipeline of commands, a process monitor.

A script that grows past fifty lines without functions turns into one long block where a change at the bottom depends on a variable set at the top. A fixed layout fixes that, and it costs nothing at runtime. The extra lines pay back the first time a bug needs a test, because a function can be called on its own while a long top-level script can only be run whole. The layout below assumes Bash 5.2; the POSIX variant is noted at the end, and the wider list of habits is in shell scripting best practices.

The skeleton

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

readonly PROG=${0##*/}
readonly DEFAULT_DIR=/var/backups

usage() {
  printf 'usage: %s [-n] [-d dir]\n' "$PROG" >&2
}

parse_args() {
  dry_run=0
  dir=$DEFAULT_DIR
  while getopts ':nd:h' opt; do
    case $opt in
      n) dry_run=1 ;;
      d) dir=$OPTARG ;;
      h) usage; exit 0 ;;
      *) usage; exit 2 ;;
    esac
  done
}

do_backup() {
  local target=$1
  echo "backing up to $target"
}

main() {
  parse_args "$@"
  do_backup "$dir"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
fi

Everything above the last block only defines things, so reading the script from the top gives a table of contents, and main shows the order of events in a few lines. Option parsing with getopts is explained in Bash getopts.

Why the source guard matters

When a file is sourced, ${BASH_SOURCE[0]} is the file and $0 is the shell, so the two differ and main does not run. When the file is executed they are equal. A test script can therefore source the file and call do_backup directly, with no side effects on load. The same idea is the basis of function libraries in Bash.

Keep variables honest

Shell
do_backup() {
  local target=$1 stamp
  stamp=$(date +%Y%m%d)
  echo "$target/$stamp.tar.gz"
}

Declare locals at the top of each function and assign separately from command substitutions, so a failing command is not hidden by local's own exit status; ShellCheck reports that as SC2155. Globals set by parse_args are the one deliberate exception, and naming them in lowercase in one place keeps them easy to find. For passing values without globals, return them on stdout and capture with $(...), as in returning values from functions.

What the structure buys you

Putting logic in functions and calling a single entry point does three useful things. It makes the order of execution explicit, so a reader who wants to know what the script does reads the last ten lines. It confines variables with local, so a loop counter in one function cannot damage another. And it makes parts testable, because a file that defines functions without running them can be sourced into a test, which calls one function with chosen inputs and checks the output. A script with everything at the top level cannot be exercised without running all of it, which is why such scripts tend to stay untested.

The source guard makes this work. A script ending in [[ ${BASH_SOURCE[0]} == "$0" ]] && main "$@" runs the program when executed and only defines functions when sourced. A test file then says source ./backup.sh, calls parse_args --keep 3, and asserts on the variables it set. Frameworks such as bats make this pattern convenient, with a test per function and readable failure messages.

Naming, ordering and size

Name functions for what they do, with verbs: parse_args, check_dependencies, make_snapshot, prune_old. Keep each one short enough to read at a glance, with a single purpose. Order them so that callers come after the things they call, or put helpers first and the main flow last, and stay consistent. A prefix for private helpers, such as a leading low line, signals that they are not part of the script's interface. Globals are allowed for configuration set once at the top, and are best marked readonly after parsing. Everything else should be local, and passed as arguments or returned through output or a named variable.

Returning values and errors

Functions in Bash return a status, a number from 0 to 255, and write results to standard output. Capturing with result=$(function) runs it in a subshell, so variables it sets are lost, and its failure must be checked on that line. For several values, use a nameref (local -n out=$1) or set a global with a documented name. Report errors to standard error with a consistent helper, die() { printf '%s: %s\n' "$PROG" "$*" >&2; exit 1; }, which keeps messages uniform and gives one place to change the format. Under set -e, remember that a failing command inside a function called in a condition does not stop the script, so check statuses explicitly in the places that matter.

Splitting across files

A script that grows past a few hundred lines can move shared helpers into a library file loaded with source. Locate it relative to the script, using the resolved path of BASH_SOURCE, so it works from any directory and through symlinks. Keep library files free of side effects on load: they should define functions and constants and nothing else. Version the interface by documenting the functions that scripts may call, and treat changes to them carefully, since many scripts may depend on them. At the point where a script needs several libraries and complex data handling, consider whether a language with modules and types would be a better home.

Checks that keep it healthy

Run ShellCheck on every change, with the same options in the editor and in CI. Add a smoke test that runs the script with --help and with a bad option and checks the exit codes. Format with shfmt so style arguments disappear from reviews. Keep a short header comment that states purpose, usage and exit codes. These small habits cost minutes and keep a script maintainable long after its author has moved on to other work.

Where this does not apply

A ten-line script does not need this ceremony, and adding it makes the file longer than the job. POSIX sh has no BASH_SOURCE, so the guard becomes a convention such as sourcing with an environment flag, or no guard at all. Functions share the caller's environment, so a function that changes directory with cd changes it for everyone after; wrap such code in a subshell ( cd dir && ... ). The Bash manual section on shell functions explains scope and return status, and the Google Shell Style Guide recommends the same main pattern for any script with more than one function.

One more convention is worth adopting: keep main short enough to read as a summary, with each line a verb such as parse_args, check_prereqs, do_backup and report. When a reader can see the order of the whole run in ten lines, the details in each helper are easier to trust. Check the finished script with ShellCheck, which also flags unused functions and variables left behind by refactoring.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.