Skip to content

Functions🔗

Part of Essentials — Bash Scripting

Sixth and final article in the Essentials Bash series. Assumes you understand Loops. The next step is the Efficiency tier, which covers production-grade patterns like set -euo pipefail, signal handling, and structured logging.

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've 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: 11increment changed the global.
  4. Output: 11safe_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:

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🔗