Skip to content

Functions๐Ÿ”—

Part of a deep dive: Bash Scripting

Consult the map

As scripts grow past 20-30 lines, repeated logic becomes a maintenance problem. A check you run in three places has to be updated in three places. Functions solve this: write the logic once, call it from anywhere, and give it a name that makes the script self-documenting.


Where You Might Have Seen This๐Ÿ”—

If you've ever sourced a setup script (source ~/.bashrc or . ./env-setup.sh), you've already used a function library โ€” that file defines functions your shell loads and can call by name. Writing your own is the same pattern.


Defining and Calling Functions๐Ÿ”—

Function Definition
1
2
3
4
check_host() {        # (1)!
    local host="$1"
    ping -c 1 "${host}" &>/dev/null
}
  1. name() { } is the standard form. An alternative function name { } syntax exists but adds nothing โ€” the first is what you'll see in most scripts.

Call a function exactly like any other command:

Calling a Function
check_host "web-01"   # (1)!
  1. Arguments work the same as script arguments โ€” $1, $2, "$@" inside the function refer to what was passed here.

Define before you call. Bash reads top to bottom โ€” a function must appear in the file before any line that calls it. The standard pattern: define all functions at the top, put the calling code at the bottom.


Arguments and Local Variables๐Ÿ”—

Functions receive arguments exactly as scripts do โ€” $1, $2, $@, $#. Always declare function variables with local โ€” without it, they're global and will overwrite variables with the same name in the main script or other functions:

local Variables
counter=10

increment() {
    counter=$(( counter + 1 ))  # (1)!
}

safe_reset() {
    local counter=0             # (2)!
    echo "Local: ${counter}"
}

increment
echo "${counter}"               # (3)!

safe_reset
echo "${counter}"               # (4)!
  1. No local โ€” this modifies the global counter.
  2. local creates a separate variable scoped to this function. The global is untouched.
  3. Output: 11 โ€” increment changed the global.
  4. Output: 11 โ€” safe_reset did not, because its counter was local.

Rule: declare all function variables with local.


Return Values๐Ÿ”—

Bash functions return exit codes (integers 0โ€“255), not values. How you get data back to the caller depends on what you need to return:

flowchart TD
    Q["What does the caller need back?"] --> A["Just pass/fail"]
    Q --> B["A single string or number"]
    Q --> C["Multiple values at once"]
    A --> A2["Exit code โ€”<br/>works directly with if"]
    B --> B2["echo, captured with $(...)"]
    C --> C2["Global variables โ€”<br/>document the coupling"]

    style Q fill:#1a202c,stroke:#cbd5e0,stroke-width:2px,color:#fff
    style A fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
    style B fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
    style C fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
    style A2 fill:#2f855a,stroke:#cbd5e0,stroke-width:2px,color:#fff
    style B2 fill:#2f855a,stroke:#cbd5e0,stroke-width:2px,color:#fff
    style C2 fill:#2f855a,stroke:#cbd5e0,stroke-width:2px,color:#fff

The natural way to signal pass/fail. Works directly with if, &&, ||, and the guard-first patterns from Conditionals:

Return via Exit Code
1
2
3
4
5
6
7
8
9
is_port_open() {
    local host="$1"
    local port="$2"
    nc -z "${host}" "${port}" &>/dev/null  # (1)!
}

if is_port_open "db-prod-01" 5432; then
    echo "Database port is open"
fi
  1. The last command's exit code becomes the function's return value. nc exits 0 if the port is open, non-zero if not โ€” so the function inherits that result automatically.

When you need to return a string rather than just pass/fail, echo the value and capture it with command substitution in the caller:

Return a String via echo
get_timestamp() {
    echo "$(date '+%Y-%m-%d %H:%M:%S')"  # (1)!
}

log_entry() {
    local message="$1"
    local timestamp
    timestamp=$(get_timestamp)            # (2)!
    echo "[${timestamp}] ${message}"
}

log_entry "Deployment started"
  1. echo to stdout is the only way to return a string from a Bash function.
  2. The caller captures it with $() โ€” the same command substitution used anywhere else.

Use only when a function needs to return multiple values. It creates invisible coupling between the function and its callers โ€” document it clearly:

Return via Global Variable
parse_version() {
    local version="$1"
    MAJOR="${version%%.*}"           # (1)!
    MINOR="${version#*.}"
    MINOR="${MINOR%%.*}"
    PATCH="${version##*.}"           # (2)!
}

parse_version "2.14.3"
echo "Major: ${MAJOR}, Minor: ${MINOR}, Patch: ${PATCH}"
  1. Sets globals directly โ€” uppercase names signal that these are intentional globals.
  2. After the call, MAJOR, MINOR, and PATCH are available in the caller's scope.

Practical Function Patterns๐Ÿ”—

A logging function is the most universally useful thing to add to any script โ€” timestamps and severity without repeating date everywhere:

Structured Logging
#!/usr/bin/env bash

log_info() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO:  $*"   # (1)!
}

log_error() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
}

log_info "Starting deployment"
log_error "Connection refused to db-prod-01"
  1. $* joins all arguments into one string โ€” appropriate for a log message, which is a single unit.

Extracting guard checks into named functions keeps main() readable โ€” the top-level flow reads as intent, not implementation:

Reusable Validation
#!/usr/bin/env bash

require_command() {
    local cmd="$1"
    if ! command -v "${cmd}" &>/dev/null; then
        echo "Error: required command '${cmd}' is not installed" >&2
        exit 1
    fi
}

require_file() {
    local path="$1"
    if [[ ! -f "${path}" ]]; then
        echo "Error: required file not found: ${path}" >&2
        exit 1
    fi
}

require_command curl
require_command jq
require_file "/etc/myapp/config.yml"

echo "All prerequisites met"

For any script beyond a few functions, wrap the entry point in main() and call it at the bottom. This means nothing runs on source โ€” every top-level statement is a function definition until main "$@":

main() Pattern
#!/usr/bin/env bash

log_info() { echo "[$(date '+%H:%M:%S')] $*"; }

setup() {
    log_info "Checking prerequisites..."
    command -v curl &>/dev/null || { echo "Error: curl required" >&2; exit 1; }
}

deploy() {
    local version="$1"
    log_info "Deploying version ${version}..."
}

verify() {
    log_info "Verifying deployment..."
}

main() {
    local version="${1:?Error: version argument required}"
    setup
    deploy "${version}"
    verify
    log_info "Done"
}

main "$@"   # (1)!
  1. The only line that runs directly โ€” passes all script arguments to main. Everything above is a definition.

Sourcing Function Libraries๐Ÿ”—

When functions are useful across multiple scripts, put them in a shared file and load it with source:

lib/functions.sh
1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash

log_info()  { echo "[$(date '+%H:%M:%S')] INFO:  $*"; }
log_error() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; }

require_command() {
    local cmd="$1"
    command -v "${cmd}" &>/dev/null || { log_error "${cmd} not found"; exit 1; }
}
deploy.sh
1
2
3
4
5
6
7
#!/usr/bin/env bash

source "$(dirname "$0")/lib/functions.sh"   # (1)!

log_info "Starting deployment"
require_command curl
require_command jq
  1. $(dirname "$0") resolves to the directory containing the running script โ€” a reliable way to find sibling files without hardcoding absolute paths.

When this pattern grows to multiple sourced libraries shared across repos, that's usually the signal to cross over. The main()/library structure maps directly to Python modules โ€” see My Bash Script Is Getting Out of Hand.


Practice Exercises๐Ÿ”—

Exercise 1: Refactor to Functions

This script has repetitive code. Refactor it using a function:

Before โ€” Repetitive
#!/usr/bin/env bash

if ping -c 1 "web-01" &>/dev/null; then
    echo "web-01: UP"
else
    echo "web-01: DOWN"
fi

if ping -c 1 "web-02" &>/dev/null; then
    echo "web-02: UP"
else
    echo "web-02: DOWN"
fi

if ping -c 1 "db-01" &>/dev/null; then
    echo "db-01: UP"
else
    echo "db-01: DOWN"
fi
Solution
After โ€” With Function
#!/usr/bin/env bash

check_host() {
    local host="$1"
    if ping -c 1 "${host}" &>/dev/null; then
        echo "${host}: UP"
    else
        echo "${host}: DOWN"
    fi
}

check_host "web-01"
check_host "web-02"
check_host "db-01"
Exercise 2: Function That Returns a Value

Write a function called get_disk_usage that:

  1. Accepts a directory path as its argument
  2. Returns (via echo) the disk usage as a percentage โ€” just the number, e.g. 63
  3. In main(), call the function and print: "/ is 63% full"
Solution
disk-check.sh
#!/usr/bin/env bash

get_disk_usage() {
    local path="$1"
    df "${path}" | awk 'NR==2 {gsub(/%/, ""); print $5}'
}

main() {
    local usage
    usage=$(get_disk_usage "/")
    echo "/ is ${usage}% full"
}

main

Quick Recap๐Ÿ”—

  • name() { } is the standard function syntax โ€” define before you call
  • Always use local for function variables โ€” undeclared variables are global and will cause subtle bugs
  • Function arguments work exactly like script arguments: $1, $2, "$@"
  • Return pass/fail via exit code; return strings via echo + $(); avoid globals except for multiple return values
  • main "$@" at the bottom of a script: the only line that runs directly, everything else is a definition
  • Shared functions go in a library file, loaded with source "$(dirname "$0")/lib.sh"

What's Next?๐Ÿ”—

You've covered the complete Bash scripting foundation: scripts, variables, arguments, conditionals, loops, and functions. The Efficiency tier builds on these with patterns for production-grade scripts โ€” set -euo pipefail, getopts, signal handling, and structured logging.


Further Reading๐Ÿ”—

Command References๐Ÿ”—

  • man bash โ€” the "Functions" section and the local and source builtins
  • help local โ€” Bash built-in help for the local keyword
  • help source โ€” how source (or .) loads function files

Deep Dives๐Ÿ”—

Official Documentation๐Ÿ”—

Exploring Python๐Ÿ”—