$linuxjunkies
>

Bash Functions and Variable Scoping

Master Bash function scoping with local variables, source-based libraries, correct use of return codes, and array passing techniques including namerefs.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

Before you start

  • Basic familiarity writing and running Bash scripts
  • Bash version 4.3 or later for nameref support (check with: bash --version)
  • A text editor and a terminal to test the examples

Bash functions let you package reusable logic, but they come with a scoping model that trips up even experienced shell programmers. Variables are global by default, return sends a numeric exit code not a string, and arrays need special handling to pass between caller and function. This guide covers all of it with working examples you can drop straight into scripts.

How Bash Scopes Variables

In Bash, every variable you assign is global unless you explicitly declare it otherwise. That means a variable set inside a function is visible — and writable — from anywhere else in the same shell session or script. This is the single biggest source of subtle bugs in shell scripts.

#!/usr/bin/env bash

my_func() {
    result="hello from function"
}

my_func
echo "$result"   # prints: hello from function

The variable result leaks into the global scope. In a short script that might be fine; in anything non-trivial it is a maintenance hazard.

Using local to Contain Variables

Declare variables with local inside a function to keep them scoped to that function and its children. This is the most important habit to build when writing non-trivial Bash.

#!/usr/bin/env bash

calculate_area() {
    local width="$1"
    local height="$2"
    local area=$(( width * height ))
    echo "$area"
}

result=$(calculate_area 5 8)
echo "Area: $result"   # Area: 40
echo "${width:-unset}" # unset — width did not leak

local accepts the same modifiers as declare: local -i for integers, local -r for read-only, local -a for indexed arrays, and local -A for associative arrays. Use these to be explicit about intent.

local -i and arithmetic

count_lines() {
    local -i count=0
    while IFS= read -r _line; do
        (( count++ ))
    done < "$1"
    echo "$count"
}

Return Codes vs. Output

Bash's return statement sends an integer exit status (0–255), not a string or object. It is purely a success/failure signal, analogous to a program's exit code. Use command substitution ($()) to capture real output.

#!/usr/bin/env bash

parse_config() {
    local file="$1"
    if [[ ! -f "$file" ]]; then
        echo "ERROR: file not found: $file" >&2
        return 1
    fi
    grep -v '^#' "$file" | grep '='
    return 0
}

# Capture output
if config_data=$(parse_config "/etc/myapp/config"); then
    echo "Parsed: $config_data"
else
    echo "Failed with exit code $?" >&2
fi

Two rules to remember: errors go to stderr (>&2), and $? holds the last exit code immediately after a command — reading it even one line late can overwrite it.

Saving $? before it disappears

some_command
local exit_code=$?   # capture immediately
if (( exit_code != 0 )); then
    echo "Command failed: $exit_code" >&2
fi

Sourcing Files to Share Functions

Source (. or source) a file to load its functions and variables into the current shell without spawning a subshell. This is how you build a library of reusable utilities.

# lib/logging.sh
log_info()  { echo "[INFO]  $(date '+%Y-%m-%dT%H:%M:%S') $*"; }
log_error() { echo "[ERROR] $(date '+%Y-%m-%dT%H:%M:%S') $*" >&2; }
#!/usr/bin/env bash
# main script

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/logging.sh"

log_info "Starting deployment"
log_error "Something went wrong"

Always use ${BASH_SOURCE[0]} rather than $0 when computing paths relative to the script file. $0 reflects how the script was invoked, which breaks when a script is itself sourced.

Guard against double-sourcing

# lib/logging.sh
[[ -n "${_LIB_LOGGING_LOADED:-}" ]] && return 0
_LIB_LOGGING_LOADED=1

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

Passing Arrays into Functions

Bash does not pass arrays by value. When you call my_func "${my_array[@]}", the array expands to individual positional parameters — the function has no idea it was originally an array. For small lists that is often fine. For structured data, use one of two proper techniques.

Technique 1: Pass by name (nameref) — Bash 4.3+

Declare a local nameref inside the function to alias the caller's array variable directly. This is the cleanest modern approach.

#!/usr/bin/env bash

print_array() {
    local -n arr_ref="$1"   # nameref to the caller's variable
    local i
    for i in "${!arr_ref[@]}"; do
        printf '  [%s] = %s\n' "$i" "${arr_ref[$i]}"
    done
}

declare -a fruits=(apple banana cherry)
print_array fruits

declare -A scores=([alice]=95 [bob]=87)
print_array scores

Pass the variable name as a string, not its expansion. The nameref sees the original, so mutations inside the function affect the caller's variable — use that power deliberately.

Technique 2: Serialize with positional parameters

When you only need to read the elements and do not need to modify them, expanding the array into positional parameters is simpler and portable to Bash 3.

sum_values() {
    local -i total=0
    local val
    for val in "$@"; do
        (( total += val ))
    done
    echo "$total"
}

numbers=(10 20 30 40)
result=$(sum_values "${numbers[@]}")
echo "Sum: $result"   # Sum: 100

Returning an array from a function

Use a nameref for the output variable, or print newline-separated values and capture with mapfile.

get_even_numbers() {
    local -n _out="$1"   # output array by name
    local -i n
    _out=()
    for (( n=2; n<=20; n+=2 )); do
        _out+=( "$n" )
    done
}

declare -a evens
get_even_numbers evens
echo "${evens[@]}"   # 2 4 6 8 10 12 14 16 18 20

Verification

Test your scoping assumptions with declare -p, which prints a variable's type and value in a machine-readable format.

my_func() {
    local secret="hidden"
    declare -p secret   # visible here
}
my_func
declare -p secret 2>&1   # bash: declare: secret: not found

Use bash -x yourscript.sh to trace execution and watch variable expansion in real time. Combine with bash -u (set -u) to have the shell error immediately on any unset variable reference — it will catch scoping bugs that would otherwise silently produce empty strings.

#!/usr/bin/env bash
set -euo pipefail   # exit on error, unset vars, pipe failures

Troubleshooting

  • Nameref circular reference: if the nameref variable name matches the local variable name (e.g. both called arr), Bash reports a circular reference error. Prefix your internal nameref variables with an underscore (_arr, _out) to avoid collisions.
  • local outside a function: Bash allows local at the top level without error in some versions but its behaviour is undefined. Always use it only inside function bodies.
  • Return code out of range: values above 255 wrap around. If you need to communicate larger integers, print them to stdout and capture with $().
  • Sourced script changes directory: wrap the sourced file in a subshell (( source file.sh )) if you want to isolate side effects like cd, but remember functions defined inside the subshell will not be available to the parent.
  • Associative arrays and namerefs on Bash < 4.3: neither feature is available. Check your target environment — macOS ships Bash 3.2 by default; install a current Bash via Homebrew or use a Linux container for scripts that rely on these features.
tested on:Ubuntu 24.04Fedora 40Debian 12Arch rolling

Frequently asked questions

Can I use local in a sourced file at the top level?
No. 'local' is only valid inside a function body. Using it at the top level of a sourced file produces undefined behaviour and an error in strict modes.
Why does my function's return value always show as 0?
You are likely reading $? after another command has already overwritten it. Capture the exit code into a variable on the very next line after the call: 'code=$?'.
Do namerefs work with associative arrays?
Yes. 'local -n ref=$1' creates a nameref that works transparently with both indexed (-a) and associative (-A) arrays, including key iteration with ${!ref[@]}.
What happens if a sourced file calls exit?
It exits the entire shell session or script, not just the sourced file. Use 'return' instead of 'exit' inside sourced library files so the caller keeps running.
Is there a way to make all variables local by default?
Not natively in Bash. Some projects simulate this with strict discipline and linting tools like ShellCheck, which warns when variables are used without local declarations inside functions.

Related guides