Bash Scripting for Engineers›03 · Functions & arguments

Lesson 03 of 8 · Modules

Functions & arguments

Structure scripts like small programs: functions with local variables and return codes, positional arguments, option parsing with getopts, a proper usage message, and messages on the right stream.

Practitioner
Key wordsfunctionslocalreturn$1 $@ $#getoptsusagestderr

Functions: name a block of steps

#!/usr/bin/env bash

log() {                                   # a tiny logger: timestamp + message, to stderr
  printf '%s %s\n' "$(date +%T)" "$*" >&2
}

backup_dir() {
  local src="$1" dest="$2"               # local: invisible outside the function
  [[ -d "$src" ]] || { log "no such directory: $src"; return 1; }
  tar -czf "$dest" -C "$src" .
  log "backed up $src to $dest"
}

backup_dir /etc/nginx "/tmp/nginx-$(date +%F).tgz" || exit 1

A function is a mini-recipe inside the big recipe: "make the sauce" can be written once and used in every dish. Arguments are the ingredients you hand it ("make the sauce with tomatoes"), and the return code says whether it worked ("sauce ready" or "burnt").

  • Functions receive arguments as $1, $2, … just like scripts.
  • return N sets the function's exit status (0 = success). To return data, print it and capture it: size=$(get_size /var/log).
  • Always declare function variables with local.

Script arguments

#!/usr/bin/env bash
echo "script: $0"
echo "first: ${1:-<none>}"
echo "count: $#"
for arg in "$@"; do echo "arg: $arg"; done
$ ./args.sh "hello world" two
script: ./args.sh
first: hello world
count: 2
arg: hello world
arg: two

A usage message and option parsing with getopts

#!/usr/bin/env bash
# rotate.sh: compress logs older than N days in a directory
usage() {
  cat >&2 <<EOF
Usage: $(basename "$0") [-d days] [-n] DIRECTORY
  -d days   age threshold (default: 7)
  -n        dry run: only print what would be compressed
  -h        this help
EOF
  exit 2
}

days=7
dry_run=false
while getopts ":d:nh" opt; do
  case "$opt" in
    d) days="$OPTARG" ;;
    n) dry_run=true ;;
    h) usage ;;
    :) echo "option -$OPTARG needs a value" >&2; usage ;;
    \?) echo "unknown option -$OPTARG" >&2; usage ;;
  esac
done
shift $((OPTIND - 1))            # drop the parsed options; $1 is now DIRECTORY

dir="${1:-}"
[[ -n "$dir" && -d "$dir" ]] || usage
[[ "$days" =~ ^[0-9]+$ ]] || { echo "-d must be a number" >&2; exit 2; }

find "$dir" -type f -name '*.log' -mtime +"$days" -print0 |
  while IFS= read -r -d '' f; do
    if $dry_run; then echo "would compress $f"; else gzip -- "$f"; fi
  done
$ ./rotate.sh -n -d 30 /var/log/myapp
would compress /var/log/myapp/app-2026-08-01.log

Key details:

  • ":d:nh": the leading : lets you handle errors yourself; d: means -d takes a value.
  • shift $((OPTIND - 1)) removes processed options.
  • Validate inputs (is it a directory? is it a number?) and exit with a clear message.
  • find -print0 with read -d '' handles file names containing spaces or newlines.
  • gzip -- "$f": -- ends options, so a file named -rf can't be misread as options.

stdout for data, stderr for messages

echo "processing $f" >&2     # diagnostics → stderr
echo "$f,$size"               # data → stdout (may be piped into another tool)

Then ./report.sh > report.csv captures clean data while progress messages still appear on screen.

Try it: a small CLI

Write userinfo.sh that:

  1. Accepts -u USER (required) and -v (verbose).
  2. Has functions usage, log (to stderr, only when -v) and user_exists (returns 0/1 using id -u "$1" &>/dev/null).
  3. Prints the user's UID, home directory and shell as key=value lines on stdout (hint: getent passwd "$user" | cut -d: -f3,6,7).
  4. Exits 2 on bad usage and 1 if the user doesn't exist.

Test: ./userinfo.sh, ./userinfo.sh -u root -v, ./userinfo.sh -u nobody-here, and ./userinfo.sh -u root > out.txt (messages still on screen, data in the file).

Going deeper: script structure

  • Put everything in functions and end the file with main "$@". The script becomes easy to read top-down, and functions can be tested (lesson 07).
  • getopts handles short options only. For long options (--days), parse "$@" with a while/case loop, or switch to Python's argparse when the interface grows.
  • Return distinct exit codes for distinct failures (2 = usage, 3 = dependency missing…) and document them. Callers such as cron, CI or systemd can react differently.
  • "$*" joins all arguments into one string (useful for log messages); "$@" keeps them separate (use it for passing arguments on).

Recap

  • Functions: name() { … }, local variables, return for status, print + $(…) for data.
  • Arguments: $1…, "$@", $#, shift, ${1:-default}.
  • getopts for options, a usage() function, and validated inputs.
  • stdout = data, stderr = messages; use -- and -print0 for untrusted file names.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.