$linuxjunkies
>

How to Write Bash Functions Properly

Learn to write reliable bash functions: safe argument handling, correct return values, proper use of local, error handling with set -e, and clean naming conventions.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

Before you start

  • Basic familiarity with writing and running bash scripts
  • bash 4.3 or later (for nameref support — check with bash --version)
  • shellcheck installed for static analysis (recommended)

Bash functions let you package logic, avoid repetition, and keep scripts readable — but they behave differently from functions in most programming languages. Argument passing, return values, and variable scoping all have sharp edges that trip up even experienced scripters. This guide covers how to write functions correctly: how to handle arguments safely, what return actually does, why you need local, how to propagate errors, and how to name things so your scripts stay maintainable.

Defining Functions

There are two valid syntaxes. Both work in bash; the second form is POSIX-portable but the first is common:

# Bash style (recommended for bash scripts)
my_function() {
    echo "hello"
}

# POSIX keyword style — also valid in bash
function my_function {
    echo "hello"
}

Stick to one style per project. The parenthesis-only form is more portable; the function keyword form does not require parentheses but is bash-only. Either way, the opening brace can be on the same line (with a space after it) or the next line.

Handling Arguments

Inside a function, $1, $2, ... hold the function's own arguments — not the script's. $0 remains the script name. $# is the argument count for the function.

greet() {
    local name="$1"
    local greeting="${2:-Hello}"   # default to "Hello" if not supplied
    echo "${greeting}, ${name}!"
}

greet "Alice"           # Hello, Alice!
greet "Bob" "Hi"        # Hi, Bob!

Always quote "$1", "$@", etc. Unquoted variables split on whitespace and glob-expand, which causes silent bugs when arguments contain spaces.

Validating Arguments

Check required arguments at the top of the function and exit early with a meaningful message:

create_user() {
    if [[ $# -lt 1 ]]; then
        echo "create_user: username required" >&2
        return 1
    fi
    local username="$1"
    useradd "$username"
}

Send error messages to stderr (>&2), not stdout, so callers can capture stdout cleanly.

Return Values and Exit Codes

This is the most misunderstood part of bash functions. return sets an integer exit status (0–255), not a string value. It does not work like return in Python or JavaScript.

is_even() {
    (( $1 % 2 == 0 ))   # arithmetic evaluates to 0 (true) or 1 (false)
}

if is_even 4; then
    echo "even"
fi

To return a string or computed value, use one of two patterns:

Pattern 1: Print to stdout and capture with command substitution

get_timestamp() {
    date +"%Y-%m-%dT%H:%M:%S"
}

ts=$(get_timestamp)
echo "Started at: $ts"

Pattern 2: Write to a named variable (nameref — bash 4.3+)

to_upper() {
    local -n _result="$1"   # nameref: _result is an alias for the caller's variable
    local input="$2"
    _result="${input^^}"
}

to_upper OUTPUT "hello"
echo "$OUTPUT"   # HELLO

The nameref approach avoids a subshell (command substitution forks a subshell, so changes to variables inside the function are lost). Use a name-mangled internal variable like _result to avoid collision with the caller's variable name.

After calling a function, check its exit status with $? or directly in an if condition. Never assume success.

Variable Scoping with local

Bash variables are global by default. Without local, any variable you set inside a function pollutes the calling scope — a common source of mysterious bugs in longer scripts.

bad_example() {
    count=0          # global! overwrites any caller's $count
    count=$(( count + 1 ))
}

good_example() {
    local count=0    # scoped to this function
    count=$(( count + 1 ))
    echo "$count"
}

Declare every variable with local inside functions unless you explicitly need to modify a global. Combine declaration and assignment on one line to avoid a subtle issue: local var=$(command) always sets $? to 0 regardless of command's exit status. When the value comes from a subshell and you need to check the exit status, split them:

process_file() {
    local content
    content=$(cat "$1")   # exit status of cat is preserved in $?
    if [[ $? -ne 0 ]]; then
        echo "process_file: could not read '$1'" >&2
        return 1
    fi
    echo "$content"
}

Error Handling Inside Functions

Scripts often run with set -e (exit on error) and set -o pipefail. Your functions must be written to work correctly under those settings.

#!/usr/bin/env bash
set -euo pipefail
  • set -e — exits the script if any command returns non-zero (with exceptions inside if, while, etc.).
  • set -u — treats unset variables as errors; forces you to initialise everything.
  • set -o pipefail — a pipeline fails if any stage fails, not just the last.

When a function may legitimately fail, use explicit return 1 rather than relying on the last command's exit status bubbling up. This makes intent clear:

backup_file() {
    local src="$1"
    local dest="${src}.bak"

    if [[ ! -f "$src" ]]; then
        echo "backup_file: '$src' not found" >&2
        return 1
    fi

    cp -- "$src" "$dest" || {
        echo "backup_file: copy failed" >&2
        return 1
    }

    echo "Backed up '$src' to '$dest'"
}

Use || with a block or a function call to handle errors inline without disabling set -e.

Naming Conventions

Consistent naming is not just style — it helps readers immediately identify scope and purpose:

  • Use lowercase with underscores for function names: parse_config, send_alert.
  • Prefix internal/private helper functions with an underscore: _validate_ip.
  • Use UPPER_SNAKE_CASE for constants and environment variables: MAX_RETRIES=5.
  • Use lowercase for all local variables inside functions.
  • Name functions as verb_noun pairs that describe what they do: get_hostname, check_dependency, rotate_logs.

Avoid single-letter names outside of simple loop counters (i, n). A name like $f inside a file-processing function tells the next reader nothing.

Putting It Together: A Practical Example

#!/usr/bin/env bash
set -euo pipefail

# Constants
readonly LOG_FILE="/var/log/deploy.log"

log() {
    local level="$1"
    local message="$2"
    printf '[%s] [%s] %s\n' "$(date +%T)" "$level" "$message" | tee -a "$LOG_FILE"
}

check_dependency() {
    local cmd="$1"
    if ! command -v "$cmd" &>/dev/null; then
        log ERROR "Required command '$cmd' not found"
        return 1
    fi
}

deploy_app() {
    local app_dir="$1"
    local version="$2"

    check_dependency git
    check_dependency rsync

    log INFO "Deploying version $version from $app_dir"
    # ... deployment logic ...
    log INFO "Deploy complete"
}

deploy_app "/srv/myapp" "1.4.2"

Verifying Your Functions

Use bash -n to syntax-check a script without running it, and shellcheck for deeper static analysis:

# Syntax check only
bash -n myscript.sh

# Install shellcheck
apt install shellcheck       # Debian/Ubuntu
dnf install ShellCheck       # Fedora/RHEL
pacman -S shellcheck         # Arch

# Run analysis
shellcheck myscript.sh

ShellCheck catches unquoted variables, misuse of local, wrong return usage, and dozens of other common errors automatically.

Troubleshooting Common Issues

  • Function not found: The function must be defined before it is called. Source order matters in bash. Move the definition above the call, or source a functions library file.
  • Variable changes inside function don't persist: You used command substitution ($()), which runs a subshell. Use namerefs or global assignment instead.
  • return exits the whole script: You called return outside a function. Use exit at the script level.
  • Exit status is always 0 after local var=$(cmd): Split declaration from assignment as shown above.
  • set -e kills the script unexpectedly: A function returned non-zero in a context where set -e is active. Wrap the call in an if statement or append || true if failure is acceptable there.
tested on:Ubuntu 24.04Fedora 40Debian 12Arch rolling

Frequently asked questions

Why doesn't return work like in Python — why can't I return a string?
In bash, return only sets an integer exit status (0–255). To pass a string back to the caller, either print it to stdout and capture with $(), or write into a caller-provided variable using a nameref (local -n, requires bash 4.3+).
I changed a variable inside a function but the change disappeared. Why?
If you called the function inside $() command substitution, it ran in a subshell — a child process whose variable changes can't reach the parent. Use a nameref or assign to a global variable directly instead.
Does local work in every shell, or just bash?
local is a bash built-in and is also supported by dash, zsh, and ksh. It is not in the POSIX standard but works reliably in all common interactive and scripting shells on Linux.
When should I use a bash function versus a separate script?
Use a function when the logic is only needed within one script or a sourced library, and the overhead of forking a new process matters. Use a separate script when the functionality should be reusable across unrelated scripts or by other programs.
Why does local var=$(failing_command) not trigger set -e?
The local builtin itself returns 0, masking the exit status of the command substitution. Always declare the variable on one line (local var) and assign on the next (var=$(cmd)) so $? reflects the command's real exit code.

Related guides