Bash Functions, Arguments and Exit Codes

Intermediate
13 min

Bash Functions, Arguments and Exit Codes

Once a script grows past twenty lines it needs structure: named functions instead of repeated blocks, arguments instead of hard-coded paths, and exit codes so that callers — cron, CI pipelines, other scripts — can tell whether it succeeded. This lesson covers all three, including the getopts builtin for proper -v/-f file option parsing and the convention that a function communicates through its exit status and its output, not through a return value.

Defining and Calling Functions

A function is a named block of commands. Define it before you call it; there is no hoisting.

bash
greet() { echo "Hello, $1" } greet "Ada" # call: no parentheses, arguments are space-separated

Inside the function, $1, $2, $@ and $# refer to the function's own arguments, shadowing the script's. $0 is still the script name.

Local Variables

By default every variable in bash is global, so a function that sets result= overwrites any result the caller had. Declare function variables with local.

bash
count_lines() { local file="$1" local n n=$(wc -l < "$file") echo "$n" } total=$(count_lines app.log)

Declare and assign on separate lines when the assignment is a command substitution; local n=$(cmd) discards the exit status of cmd because local itself succeeds.

Returning Values

return sets the exit status of the function (0–255), not a value. Functions "return" data by printing it, and the caller captures it with $( ).

bash
is_root() { [[ $EUID -eq 0 ]]; } # status is the last command's status is_root && echo "running as root" get_ip() { ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | head -1 } addr=$(get_ip) validate_port() { local p="$1" [[ "$p" =~ ^[0-9]+$ ]] && (( p >= 1 && p <= 65535 )) || return 1 } if validate_port "$PORT"; then echo "ok"; else echo "bad port" >&2; fi

Script Arguments

| Variable | Meaning | |---|---| | $0 | script name as invoked | | $1 … $9, ${10} | positional arguments | | $# | number of arguments | | "$@" | all arguments, each preserved as one word | | "$*" | all arguments joined into one string | | shift | drop $1, moving the rest down |

bash
#!/usr/bin/env bash if (( $# < 1 )); then echo "usage: $0 <file>..." >&2 exit 64 fi for f in "$@"; do echo "processing $f" done first="$1"; shift # now "$@" holds the remaining arguments

Always use "$@" with quotes. $* and unquoted $@ split arguments containing spaces into pieces.

Parsing Options with getopts

getopts handles short options (-v, -f file) with correct error messages and combined flags (-vf file).

bash
#!/usr/bin/env bash verbose=0; output="" while getopts ":vo:h" opt; do case "$opt" in v) verbose=1 ;; o) output="$OPTARG" ;; h) echo "usage: $0 [-v] [-o file] <input>"; exit 0 ;; :) echo "option -$OPTARG requires a value" >&2; exit 64 ;; \?) echo "unknown option -$OPTARG" >&2; exit 64 ;; esac done shift $((OPTIND - 1)) # remove parsed options, leaving positional args input="${1:?input file required}"

The leading : in the option string enables silent error handling so your case gets : and ? instead of bash printing its own messages. o: means -o takes a value, available as $OPTARG. For long options (--output) use a manual while/case loop or the external getopt.

Exit Codes

Every command ends with a status from 0 to 255. Scripts exit with the status of their last command unless you call exit n explicitly. Callers check $? or use the script directly in if, && and ||.

| Code | Conventional meaning | |---|---| | 0 | success | | 1 | general error | | 2 | misuse of a shell builtin or bad usage | | 64–78 | sysexits.h conventions: 64 usage, 66 no input, 77 permission, 78 config | | 126 | command found but not executable | | 127 | command not found | | 128+n | killed by signal n (130 = Ctrl+C, 137 = SIGKILL) |

bash
#!/usr/bin/env bash if ! command -v jq > /dev/null; then echo "jq is required" >&2 exit 127 fi curl -sf "https://api.example.com/health" > /dev/null || exit 1 echo "healthy" exit 0
bash
./check.sh; echo "status: $?" ./check.sh && deploy.sh

Send error messages to standard error (>&2) so they do not pollute captured output, and reserve standard output for the script's actual result.

A Structured Script Skeleton

bash
#!/usr/bin/env bash set -u readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >&2; } die() { log "ERROR: $*"; exit 1; } usage() { echo "usage: $0 [-v] <env>" >&2; exit 64; } main() { local env="${1:-}" [[ -n "$env" ]] || usage log "deploying to $env" deploy "$env" || die "deploy failed" log "done" } main "$@"

Putting the logic in main and calling it last means every function is defined before it runs, and the script can be sourced for testing without executing anything.

Common Mistakes

  • Calling a function with parentheses: greet("Ada") is a syntax error.
  • Expecting return "text" to work; return takes only a number.
  • Forgetting local, which lets a helper silently overwrite the caller's variables.
  • Using $* or unquoted $@ in a loop.
  • Printing errors to stdout, which corrupts output that another script captures.
Quick Quiz
Question 1 of 3

How does a bash function return a string to its caller?

Key Takeaways

  • Define functions with name() { ... } and call them without parentheses; use local for every variable inside.
  • Functions return status with return n and data by printing it; capture with $( ).
  • "$@" preserves arguments exactly; $# counts them; shift consumes them.
  • getopts ":vo:h" parses short options; $OPTARG holds values and shift $((OPTIND-1)) clears them.
  • Exit 0 on success, non-zero on failure, and write errors to stderr with >&2.

Next lesson: Bash Error Handling and Writing Robust Scripts — make scripts stop on the first error, clean up after themselves and pass a linter.

Bash Functions, Arguments and Exit Codes - Linux & Command Line | CodeYourCraft | CodeYourCraft