Signal9Signal9 — home
online
all lessons

Functions: Reusable Code

Intermediate35 min6 steps
What you'll learn
  • Explain what a function is — a named mini-command you define — and why real scripts are built from them
  • Define and call functions with parameters
  • Keep variables local, and explain the action-at-a-distance bug that local prevents
  • Return status with exit codes and return data by output — and know why they're different channels
  • Handle a script's arguments and exit codes (its interface to the outside world)
  1. Why functions: you're building your own commands

    Build the mental model — a function is a command you taught the shell

    You already think in commands: ls lists, grep searches, du measures. Each is a black box with a job, inputs, and a success/failure verdict. A function is exactly that — except you define what it does. deploy_app, check_disk, log_error: once defined, you call them like any other command, arguments and all.

    That's a more useful mental model than "reusable code block," because it tells you how functions behave: they take arguments the same way commands do, they report success or failure the same way commands do (exit codes — the currency you learned with conditionals), and they slot into if, &&, and pipelines the same way commands do. Nothing new to learn about using them; the news is that you can make them.

    Why bother, in a 30-line script? Three compounding reasons:

    1. One place to fix. If timestamp formatting lives in a log function, changing the format is a one-line edit — not a hunt through every echo in the file.

    2. Readable at a glance. A main body that reads check_prereqs, backup_db, rotate_logs is a table of contents. The same logic inlined is a wall of plumbing.

    3. Testable in isolation. You can paste one function into a terminal and poke at it — try that with line 47 of a monolithic script.

    Check yourself

    A function reports success or failure to its caller. Based on how everything else in the shell works, what mechanism would you predict it uses?

  2. Define and call a function

    Package logic under a name and pass it parameters

    The syntax is minimal: a name, empty parentheses (always empty — they're just a marker), and a body in braces. Calling it looks exactly like running a command — no parentheses at the call site, arguments separated by spaces.

    Inside the body, arguments arrive as positional parameters: $1 is the first, $2 the second, and so on — the same convention a script uses for its arguments. One rule of order: the definition must be read by the shell before the call, which is why functions live at the top of a script.

    greet_user() {
      echo "Good $2, $1!"
    }
    greet_user Alice morning
    Good morning, Alice!
    Alice lands in $1, morning in $2, and the body weaves them into the message. Notice what the call looks like: greet_user Alice morning — indistinguishable from any two-argument command. That's the point.
    log() {
      echo "[$(date +%H:%M:%S)] $*"
    }
    log "backup starting"
    log "copied" 42 "files"
    [14:02:11] backup starting
    [14:02:11] copied 42 files
    $*
    all positional arguments joined into one string — right for building a message
    +%H:%M:%S
    (date) format: hours:minutes:seconds
    The single most-defined function in operations: a timestamped logger. $* means "all the arguments, joined" — so log accepts any number of them. Define this once at the top of a script and every status line gets a timestamp for free; change the format later and you change it in exactly one place.

    Check yourself

    Inside the function body, how do you refer to the second argument it was called with?

  3. Keep variables local

    Stop a function's variables leaking into the rest of the script

    Here's the sharpest edge in Bash functions, and it's invisible until it cuts you: a variable assigned inside a function is global by default. Set result=5 in a helper and you've set result for the entire script — including whatever value the caller had in a variable of the same name. The function reaches out and silently overwrites state it was never meant to touch: action at a distance.

    The fix is one word. Declare the variable `local` and it exists only inside that function call, vanishing when the function returns — like notes on scratch paper versus writing on the shared whiteboard.

    bad() { result="leaked"; }
    good() { local result="contained"; }
    result="original"
    bad
    echo "after bad:  $result"
    result="original"
    good
    echo "after good: $result"
    after bad:  leaked
    after good: original
    Same shape, one word different. bad's assignment escaped and clobbered the caller's variable; good's local kept it contained, and the caller's value survived. Read that output twice — it's the entire scoping story in four lines.

    Check yourself

    Which keyword confines a variable to the function it's declared in?

  4. Return status vs return data — two different channels

    Signal success/failure via exit code, hand back values via output

    Coming from Python or JavaScript you'd expect return some_string. Bash will surprise you: `return` only carries a number from 0 to 255 — it sets the function's exit code, nothing else. So functions have two separate channels, and choosing the right one is what makes them idiomatic:

    • Status — the yes/no verdict. return 0 = success, return 1(+) = failure; if you don't return explicitly, the function inherits the exit code of its last command. This channel plugs straight into if and &&.
    • Data — an actual value. The function echoes it, and the caller captures with $( ) — the same command substitution you'd use for date or hostname, because a function is a command.
    is_root() { [ "$EUID" -eq 0 ]; }
    if is_root; then echo "running as root"; else echo "not root"; fi
    not root
    EUID
    effective user ID of the shell — 0 means root
    A predicate function — the status channel in its purest form. The body is a single test; the function's exit code IS that test's exit code (no explicit return needed). That's why is_root slots directly into if with no brackets, no comparison — it's a command answering yes or no.
    get_backup_name() {
      echo "backup-$(hostname)-$(date +%F).tar.gz"
    }
    filename=$(get_backup_name)
    echo "writing to: $filename"
    writing to: backup-web01-2026-07-10.tar.gz
    The data channel: the function builds a value and echoes it; the caller captures it with $( ) into a variable. This pair — echo inside, $( ) outside — is Bash's version of 'return a string'.

    Check yourself

    Your function computed the string "/srv/backups/monday". How does it hand that back to the caller — return, or something else?

  5. A script's interface: arguments and exit codes

    Validate input, report usage to stderr, and exit non-zero like a proper tool

    Zoom out one level: everything a function does with $1 and exit codes, a script does with the outside world. Your script's arguments and its final exit code are its interface — they're what cron, CI pipelines, and other scripts see. A well-behaved tool holds up three ends of the contract: it validates its arguments before acting, it prints usage errors to stderr (so they don't pollute captured output), and it exits non-zero on failure (so callers can tell).

    cat > needs-arg.sh <<'EOF'
    #!/bin/bash
    if [ "$#" -lt 1 ]; then
      echo "usage: $0 <file>" >&2
      exit 2
    fi
    echo "processing $1"
    EOF
    cat needs-arg.sh
    #!/bin/bash
    if [ "$#" -lt 1 ]; then
      echo "usage: $0 <file>" >&2
      exit 2
    fi
    echo "processing $1"
    $#
    number of arguments the script received — check it before touching $1
    >&2
    redirect this output to stderr — the channel for errors and usage, not data
    $0
    the script's own name — so usage messages stay correct if the file is renamed
    The guard every script should open with. $# is the argument count — if nothing was passed, print a usage line naming the script ($0), send it to stderr with >&2, and exit 2. Now ./needs-arg.sh || echo failed works, cron notices the failure, and a pipeline that captures stdout doesn't end up with a usage message inside its data.
    cat > getopts.sh <<'EOF'
    #!/bin/bash
    while getopts "vf:" opt; do
      case "$opt" in
        v) verbose=1 ;;
        f) file="$OPTARG" ;;
        *) echo "usage: $0 [-v] [-f file]" >&2; exit 2 ;;
      esac
    done
    EOF
    cat getopts.sh
    #!/bin/bash
    while getopts "vf:" opt; do
      case "$opt" in
        v) verbose=1 ;;
        f) file="$OPTARG" ;;
        *) echo "usage: $0 [-v] [-f file]" >&2; exit 2 ;;
      esac
    done
    vf:
    accept -v (no value) and -f VALUE (colon = takes an argument)
    OPTARG
    holds the value supplied to the current flag
    Real flag parsing — the step up from positional arguments. The option string "vf:" declares two flags: v takes no value, f does (that's the colon), and the value lands in $OPTARG. Users can now write -v -f data.txt, -vf data.txt, or -f data.txt -v and they all parse. The * arm catches unknown flags with a proper usage-and-exit.

    getopts is for options (-v, -f file); when a script just takes a list of positional arguments to work through one by one, the idiom is `shift`. shift throws away $1 and slides every remaining argument down a slot — the old $2 becomes $1, $3 becomes $2, and $# drops by one. Loop while [ "$#" -gt 0 ], handle $1, then shift, and you consume the whole argument list cleanly no matter how long it is. (shift 2 drops two at once.) Reach for shift to walk a variable-length list of files or names; reach for getopts when you're parsing flags.

    cat > shift-demo.sh <<'EOF'
    #!/bin/bash
    while [ "$#" -gt 0 ]; do
      echo "handling: $1"
      shift
    done
    EOF
    bash shift-demo.sh alpha beta gamma
    handling: alpha
    handling: beta
    handling: gamma
    The loop handles $1, then shift promotes the next argument into $1, so the same two lines process every argument in turn until $# reaches 0. Run it with two args or ten — the loop doesn't care. This is the positional-list counterpart to the getopts flag-parsing loop above.

    Check yourself

    Why does the usage message go to stderr (>&2) instead of plain stdout?

  6. Now do it — build a tiny function library in your live terminal

    Write, break, and fix the three core patterns yourself

    Functions become natural the day you stop copying them and start composing them. Build a three-function toolkit — a logger, a predicate, a data function — then deliberately reproduce the two classic bugs (the global leak and the crossed channel) so you'll recognize them in the wild.

    log() { echo "[$(date +%H:%M:%S)] $*"; }
    is_num() { [[ "$1" =~ ^[0-9]+$ ]]; }
    log "starting"
    if is_num 42; then log "42 is a number"; fi
    is_num "forty-two" || log "forty-two is not"
    A logger (data assembly with $*) and a predicate (a regex test whose exit code IS the answer). Notice is_num working both ways — inside an if, and with || — because it speaks the exit-code contract every shell construct understands.
    greet() { local who="${1:-world}"; echo "hello, $who"; }
    greet
    greet Alice
    A local variable plus a default value: ${1:-world} means "$1, or 'world' if unset/empty". Call it both ways and watch the default kick in. Defaults make functions forgiving — callers supply only what's non-obvious.
    counter() { n=99; }
    n=1
    counter
    echo "n is now: $n"
    Reproduce the leak: counter forgot local, so your n=1 is gone — the echo prints 99. Now add local (counter() { local n=99; }), reset n=1, and run again: n survives. You've just fixed the exact bug that eats hours in real scripts.
    get_name() { echo "probing..."; echo "web01"; }
    name=$(get_name)
    echo "got: [$name]"
    Reproduce the crossed channel: the debug echo gets captured along with the answer — the brackets show 'probing...' welded into the value. Fix it by sending the debug line to stderr (echo "probing..." >&2) and capture again: clean. Data on stdout, diagnostics on stderr — forever.
Recap
  • A function is a command you define: name() { ... } before first use, called like any command, arguments arriving as $1, $2, … — real scripts are a stack of functions plus a short main body.
  • Variables assigned in functions are GLOBAL unless you say local — one missing keyword lets a helper silently clobber its caller's state (the loop-counter-named-i bug).
  • Two return channels: status (exit code via return / last command — plugs into if, &&) and data (echo + capture with $( )). Never mix debug output into the data channel; send it to stderr.
  • A script's interface is its arguments and exit code: validate $# first, print usage to stderr (>&2), exit non-zero on failure, parse flags with getopts, and pass arguments along as "$@".
  • Defaults like ${1:-fallback} make functions forgiving; predicates (a function whose body is a test) make conditionals read like English: if is_root; then …
Concept → syntax
ConceptSyntax
Define / callf() { ...; } · f arg1 arg2
Local variablelocal x=...
Default value${1:-fallback}
Return statusreturn 0 / non-zero → use in if f; then
Return dataecho val inside → x=$(f) outside
Debug from a data functionecho "msg" >&2
Validate args[ "$#" -lt 1 ] && { echo "usage: $0 ..." >&2; exit 2; }
Parse optionswhile getopts "vf:" o; do ... $OPTARG