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.
A function is a named block of commands. Define it before you call it; there is no hoisting.
greet() {
echo "Hello, $1"
}
greet "Ada" # call: no parentheses, arguments are space-separatedInside the function, $1, $2, $@ and $# refer to the function's own arguments, shadowing the script's. $0 is still the script name.
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.
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.
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 $( ).
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| 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 |
#!/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 argumentsAlways use "$@" with quotes. $* and unquoted $@ split arguments containing spaces into pieces.
getopts handles short options (-v, -f file) with correct error messages and combined flags (-vf file).
#!/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.
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) |
#!/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./check.sh; echo "status: $?"
./check.sh && deploy.shSend error messages to standard error (>&2) so they do not pollute captured output, and reserve standard output for the script's actual result.
#!/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.
greet("Ada") is a syntax error.return "text" to work; return takes only a number.local, which lets a helper silently overwrite the caller's variables.$* or unquoted $@ in a loop.How does a bash function return a string to its caller?
name() { ... } and call them without parentheses; use local for every variable inside.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.>&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.