TechEarl

Bash Functions: Definition, Arguments, Return Values, and Scope

Bash function reference: two declaration syntaxes, positional arguments via $1/$@/$*, returning data via stdout vs return-code exit status, local variables and the global-by-default scoping trap, recursion, namerefs, and function libraries via source.

Ishan KarunaratneIshan Karunaratne⏱️ 13 min readUpdated
Bash functions reference: definition syntax, $1/$@/$*/$# arguments, return code vs stdout for data, local variables, recursion, namerefs, source-loaded libraries, and export -f.

Bash functions look like familiar functions and behave nothing like them. There's no parameter list in the declaration — arguments come through $1, $2, $@ exactly like a script's command-line arguments. There's no return value for data — return N sets an exit code in 0-255, and "returning" actual data means writing to stdout and capturing with $(...). Variables default to global scope inside functions, which silently rewrites the caller's state unless you mark them local. Below is the full reference: two declaration syntaxes, argument handling, return-vs-stdout semantics, local scope, recursion, namerefs for pass-by-reference, source-loaded libraries, and the pitfalls that catch every Bash newcomer.

How do I define a Bash function?

A Bash function is a named block of commands that runs when you call its name. The two valid declaration syntaxes are name() { ...; } (POSIX, portable) and function name { ...; } (Bash-only, no parentheses needed). Arguments are accessed positionally inside the function via $1, $2, ... $9, ${10} for the tenth, $@ for all arguments as separate quoted words, $* for all joined into one string by $IFS, and $# for the count. The return N statement sets an integer exit code from 0 to 255 — it is NOT for returning data. To return data, write to stdout with echo or printf and capture in the caller with result=$(my_function arg). Variables default to GLOBAL scope inside a function; use local var=value to scope them to the function. Define functions in the script, in ~/.bashrc, or in a separate library file loaded with source library.sh. To make a function available to subshells (including those spawned by find -exec), export with export -f my_function. For the conditional and loop constructs you'll use inside functions, see Bash if, else, and elif and Bash For Loops.

Jump to:

The two declaration syntaxes

bash
# POSIX-style (portable to sh, dash, ksh)
greet() {
    echo "Hello, $1"
}

# Bash-only "function" keyword (parens optional)
function greet {
    echo "Hello, $1"
}

# Both work the same way
greet "world"          # Hello, world

A few rules:

  • No spaces inside the parens. greet () works; greet( ) does too; greet ( ) is fine. The parens just signal "this is a function".
  • No argument list in the parens. Unlike C or Python, Bash function parens are always empty. Arguments come through $1, $2, etc.
  • The body is enclosed in { ... }. The opening brace needs whitespace around it (function greet { works, function greet{ doesn't).
  • Functions must be defined before they're called. Bash reads top-to-bottom; calling a function above its definition fails.

Prefer the POSIX style — greet() { ... } — for portability and consistency with the rest of the shell ecosystem. The function keyword is a Bashism with no practical advantage.

Arguments: $1, $@, $*, and $#

Inside a function, the positional parameters refer to the function's arguments (not the script's):

bash
demo() {
    echo "Function name: $0 — actually NO, \$0 is the script name"
    echo "First arg:  $1"
    echo "Second arg: $2"
    echo "Count:      $#"
    echo "All args:   $@"
}

demo apple banana cherry
# First arg:  apple
# Second arg: banana
# Count:      3
# All args:   apple banana cherry

Two surprises:

  • $0 is still the script name inside the function, NOT the function name. Use ${FUNCNAME[0]} for the function name.
  • For positional args past 9, use braces: ${10}, ${11}, etc. $10 is $1 followed by the literal 0.

To shift arguments off the front of the list (consume them one at a time):

bash
process_flags() {
    while (( $# > 0 )); do
        case "$1" in
            -v|--verbose) verbose=1; shift ;;
            -n|--name)    name="$2"; shift 2 ;;
            *)            echo "Unknown: $1"; shift ;;
        esac
    done
}

process_flags --verbose --name "Ishan"

shift removes $1 and shifts the rest down. shift 2 removes the first two. The pattern is the same as in a script that processes its own $@.

"$@" vs "$*" (the quoting matters)

Same gotcha as ${arr[@]} vs ${arr[*]} from Bash Arrays.

FormBehavior
"$@"Each argument as a separate quoted word. What you want for forwarding.
$@Each argument word-split on $IFS. Breaks on args containing spaces.
"$*"All arguments joined into ONE string with the first char of $IFS between them.
$*Like "$*" then word-split. Almost never useful.

The canonical forwarding pattern:

bash
log_and_run() {
    echo "Running: $@" >&2
    "$@"               # ← quoted "$@" forwards args correctly to the underlying command
}

log_and_run rm -rf "/tmp/my dir with spaces"   # works correctly

Without the quotes, /tmp/my dir with spaces becomes three arguments (/tmp/my, dir, with, spaces) and rm deletes the wrong things. Always use "$@" when forwarding arguments to another command.

Return values: exit code vs stdout

Bash functions have two return channels and they're easy to confuse:

  1. Exit code via return N — an integer 0-255 indicating success (0) or failure (non-zero). NOT for data.
  2. stdout via echo / printf — the way to "return" actual data, captured by the caller with $(...).
bash
# Exit code: success/failure signal
is_dir() {
    [ -d "$1" ] && return 0 || return 1
}

if is_dir /etc; then
    echo "yes"
fi

# Stdout: actual data
slugify() {
    echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-'
}

result=$(slugify "Hello World")
echo "$result"           # hello-world

Three things that bite people:

  • return only accepts 0-255. return 256 wraps to 0; return -1 wraps to 255. Treat it as an 8-bit value.
  • You can't return a string. return "hello" errors out (or returns 0 if hello evaluates to nothing).
  • Capturing stdout is $(function_name args), not function_name args alone. Forgetting the $( ) runs the function but throws away the output.

A common hybrid pattern: use stdout for the "result" AND return code for "did it succeed":

bash
fetch_user_email() {
    local user_id="$1"
    if [ -z "$user_id" ]; then
        echo "missing user_id" >&2
        return 1
    fi
    # Pretend this hits a DB
    echo "user-${user_id}@example.com"
    return 0
}

if email=$(fetch_user_email 42); then
    echo "Got: $email"
else
    echo "Lookup failed" >&2
fi

Stdout carries the data, return code carries the status, stderr carries the error message. Three separate channels in one function.

Local variables and the global-by-default trap

By default, variables inside a function are global. They modify the caller's state.

bash
# ❌ BAD — overwrites the caller's $i
process() {
    for i in 1 2 3; do echo "$i"; done
}

i="important value"
process
echo "After: $i"           # After: 3   (silently corrupted)

This is one of Bash's worst defaults. The fix: mark every function-internal variable with local.

bash
# ✅ Good — local i is scoped to the function
process() {
    local i
    for i in 1 2 3; do echo "$i"; done
}

i="important value"
process
echo "After: $i"           # After: important value

local accepts the same initializer syntax as a regular assignment:

bash
my_func() {
    local count=0
    local name="$1"
    local -a items=()       # local indexed array
    local -A map            # local associative array (Bash 4+)
    local -r constant=42    # readonly local
}

local only works inside a function. Outside a function it's a syntax error.

Make local a reflex for every loop variable, counter, and temp value in every function. It's the single change that turns brittle "works once" scripts into reusable function libraries.

Recursion

Bash functions can call themselves. Useful for tree traversal, factorial-style computation, parser recursion.

bash
factorial() {
    local n="$1"
    if (( n <= 1 )); then
        echo 1
    else
        echo $(( n * $(factorial $((n - 1))) ))
    fi
}

factorial 5    # 120
factorial 10   # 3628800

Bash has no tail-call optimization. Each recursive call uses a stack frame. For factorial 1000 you'll hit FUNCNEST limits (default 0, meaning unlimited, but stack-overflow eventually). For real recursion-heavy workloads (filesystem walking, JSON processing), reach for find, jq, or a different language.

A more practical example — recursive directory size summary:

bash
dir_size() {
    local dir="$1"
    local total=0
    local item
    for item in "$dir"/*; do
        if [ -d "$item" ]; then
            total=$(( total + $(dir_size "$item") ))
        elif [ -f "$item" ]; then
            total=$(( total + $(stat -f %z "$item" 2>/dev/null || stat -c %s "$item") ))
        fi
    done
    echo "$total"
}

dir_size /tmp

(Note: du -s is faster and more accurate; this is for illustration of recursive function structure.)

Pass-by-reference with declare -n

Bash 4.3+ supports namerefs — variables that hold the name of another variable, letting a function modify the caller's data in place.

bash
append_unique() {
    local -n arr_ref="$1"   # nameref to the array passed by name
    local value="$2"
    local existing
    for existing in "${arr_ref[@]}"; do
        [ "$existing" = "$value" ] && return 0
    done
    arr_ref+=("$value")
}

fruits=("apple" "banana")
append_unique fruits "cherry"
append_unique fruits "apple"   # already present, no-op
echo "${fruits[@]}"            # apple banana cherry

The function takes the name of the array as $1 and binds a local nameref to it. Mutations through arr_ref affect the caller's fruits directly.

Pre-4.3 fallback: pass the array values, modify a copy, echo back, and have the caller rebuild. Verbose but portable.

Namerefs require Bash 4.3+. macOS default Bash 3.2 doesn't support them — see the macOS compatibility section in Bash Arrays.

Function libraries: source and export -f

Functions can be loaded from a separate file with source (or its alias .):

bash
# In ~/lib/string_utils.sh
slugify() {
    echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-'
}

trim() {
    local s="$1"
    s="${s#"${s%%[![:space:]]*}"}"
    s="${s%"${s##*[![:space:]]}"}"
    echo "$s"
}
bash
# In your script
source ~/lib/string_utils.sh

slug=$(slugify "Hello World")
clean=$(trim "  spaced out  ")

source runs the file in the current shell — so functions defined there become available, and so do any variable assignments. (bash file.sh runs it in a subshell, which is wrong for a library.)

Functions are NOT inherited by subshells spawned by find -exec, xargs, or bash -c. To make a function available there, export it:

bash
process_file() {
    echo "Processing: $1"
}
export -f process_file

find . -name "*.txt" -exec bash -c 'process_file "$0"' {} \;

export -f ships the function definition into the environment so subshells can see it. Without it, the subshell's process_file is undefined.

Real-world function libraries show up in:

Common pitfalls

1. Forgetting local. Variables default to global. Loop counters and temps inside a function silently overwrite caller state. Make local a reflex.

2. Using return for data. return is an integer 0-255 exit code, NOT a data-return mechanism. Use echo + $(...) for data.

3. return "string". Errors out or returns 0. Bash doesn't have string-typed return values.

4. $0 inside a function is the script name, not the function name. Use ${FUNCNAME[0]} if you need the function's own name (for logging, errors).

5. Forwarding args without quotes. inner_command $@ breaks on args containing spaces. Always inner_command "$@".

6. $10 parses as $1 then 0. Use ${10}, ${11}, etc. for positional args 10+.

7. Defining a function after its first call. Bash reads top-to-bottom; the function must be defined before any line that calls it. Put definitions at the top of the script.

8. Function name colliding with a builtin or command. function ls() { ... } overrides the real ls for the rest of the shell session. Use command ls to bypass the override; better, choose a non-colliding name.

9. Forgetting export -f for find -exec / xargs. Subshells don't inherit shell functions by default. Either export -f or use a wrapper script.

10. Pipe subshell scoping. ... | my_function runs the function in a subshell. Variables it sets are lost when the pipe closes. Same fix as for Bash While Loops: use process substitution or redirect from a file.

Debugging Bash functions

bash
# Trace function calls
set -x
my_function arg1 arg2
set +x

# See what's defined as a function
declare -F                # list all function names
declare -f my_function    # show the body of one function
type my_function          # show what 'my_function' resolves to (function, alias, builtin, file)

# Caller info (useful for error reporters inside functions)
my_func() {
    echo "Called from line ${BASH_LINENO[0]}"
    echo "Function name: ${FUNCNAME[0]}"
}

# Static analysis catches every pitfall above
shellcheck script.sh

declare -F + declare -f my_function together tell you exactly what's loaded in the shell — invaluable when sourcing multiple libraries and figuring out which one defined a colliding name.

shellcheck flags missing local, unquoted $@, return-with-string mistakes, and unused function parameters. Run it on every script.

What to do next

FAQ

TagsBashShell ScriptingLinuxfunctionsscopelocal
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Bash for loop reference: brace-range {1..10}, sequence (seq), array, glob, C-style, nested, parallel with xargs. Plus safe file iteration with find -print0, globbing pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ differences.

Bash For Loops: Syntax, Examples, and One-Liners

Every form of the Bash for loop with working examples: brace-range, sequence-expression, array, glob, C-style, nested, and parallel. Plus the safe file-iteration patterns, common pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ gotchas.