Bash Functions: Arguments, Variables and Return Values

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
Applies to Linux distributions with Bash installed
Privilege Normal user
Scope Define and call Bash functions, pass arguments, use local and global variables, return status versus stdout and nameref data return, dynamic scope, subshell effects, recursion limits, and a small reusable function library. Does not cover full script argument parsing, command-substitution rules, or loading libraries with source.
Related guides Bash shell script tutorial
Shell scripting course hub
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

Bash functions group reusable commands under a name. They accept arguments through $1, $2, and "$@", can keep temporary variables local, and report success with an exit status or return data through stdout.

A function call runs in the current shell by default, not a subshell. That matters for variable updates, for how return differs from exit, and for why Bash local variables follow dynamic scope rather than lexical scoping. The examples below are self-contained snippets you can paste into a terminal or a .sh file.


Define and Call a Bash Function

A function groups commands under one name. Define it once, then call it like any other command. Bash runs the body in the current shell process, so ordinary assignments inside the function can change caller-visible variables unless you mark them local.

Start with a one-line logger you can reuse in longer scripts:

bash
log() {
  printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2
}

Call it with a message string:

bash
log "starting backup"

Sample output:

output
[23:31:10] starting backup

The timestamped log goes to standard error, which keeps stdout free for functions that need to return data through command substitution later. This logger intentionally uses "$*" because it wants all supplied words combined into one message; use "$@" when forwarding arguments to another command.


Two Function Declaration Forms

Bash accepts two declaration styles:

bash
greet() {
  echo "hello"
}

name() { ...; } is the portable function syntax and is also the common style in Bash. The function keyword form is Bash-specific:

bash
function greet2 {
  echo "hello again"
}

Call either form by name:

bash
greet
greet2

Sample output:

output
hello
hello again

For new Bash scripts, prefer name() { ... }. Pick one form per project and stay consistent.


Pass Arguments to Functions

Inside a function, positional parameters work like script arguments. For a full walkthrough of script-level positional parameters, see Bash script arguments; flag parsing with getopts lives in Bash getopts. Here the focus is how positional parameters behave inside a function body.

Parameter Meaning
$1, $2 First and second function arguments
${10} Tenth argument and beyond
$# Number of arguments
"$@" Each argument preserved as a separate word
"$*" All arguments combined into one string when quoted
$0 Script or shell name, not the function name
${FUNCNAME[0]} Current function name

Use braces for arguments 10 and above, such as ${10} and ${11}. Writing $10 expands to ${1} followed by a literal 0, which is a common beginner mistake.

$0 does not change inside a function. It still names the shell or script, not the function.

Define a small inspector and call it with two arguments:

bash
show_args() {
  echo "count=$# first=$1"
}

show_args "one word" two

Sample output:

output
count=2 first=one word

Quoted "$@" preserves each argument as its own word. That matters when an argument contains spaces:

bash
show_words() {
  printf 'arg=<%s>\n' "$@"
}

show_words "one word" two

Sample output:

output
arg=<one word>
arg=<two>

Quoted "$*" joins all arguments into one string using the first character of IFS as the separator. Use "$@" when you forward arguments to another command or need each value to stay separate.

Check that a required argument was passed before you use it. The $# comparison uses Bash arithmetic in (( )); checking the count before reading $1 also avoids an unbound-variable error in scripts that enable set -u:

bash
greet() {
  if (( $# < 1 )); then
    printf 'usage: greet NAME\n' >&2
    return 2
  fi
  printf 'Hello, %s\n' "$1"
}

Call it with and without a name:

bash
greet Alice
greet
echo "missing-arg status=$?"

Sample output:

output
Hello, Alice
usage: greet NAME
missing-arg status=2

When a function returns, Bash restores the caller's positional parameters. Define inner separately so the example teaches one concept at a time:

bash
inner() {
  echo "inner: 1=$1"
}

outer() {
  echo "outer before: 1=$1 2=$2"
  inner "inner-only"
  echo "outer after: 1=$1 2=$2"
}

outer alpha beta

Sample output:

output
outer before: 1=alpha 2=beta
inner: 1=inner-only
outer after: 1=alpha 2=beta

The outer function keeps its own $1 and $2 even after inner runs with different arguments.


Global vs local Variables

A variable assignment inside a function is not automatically local. If no local variable with that name exists in the active function call chain, the assignment affects the global variable. That is easy to miss if you expect function-local scope by default.

See what happens without local:

bash
f_global() { x=2; }
x=1
f_global
echo "x after f_global=$x"

Sample output:

output
x after f_global=2

The outer x changed because f_global wrote to the global name. Mark temporaries with local when the value should not leak out:

bash
f_local() {
  local x=3
  echo "inside x=$x"
}
x=1
f_local
echo "x after f_local=$x"

Sample output:

output
inside x=3
x after f_local=1

Treat every scratch variable in a library-style function as local unless you deliberately want to modify caller-visible state. The Bash variables guide covers assignment rules and declare in more depth.


Return Success or Failure with return

A function does not need an explicit return. If execution reaches the closing }, the function's status is the exit status of the last command that ran.

bash
is_file() {
  [[ -f "$1" ]]
}

Test whether a path is a regular file:

bash
if is_file /etc/passwd; then echo "exists"; fi

Sample output:

output
exists

return N sets the function's exit status explicitly when you need a specific code. It is not a general-purpose way to pass strings or large integers. Function status is an 8-bit value from 0 to 255. If you pass a larger integer to return, Bash keeps its least significant 8 bits.

Build a port check that relies on the implicit status of its last command. The digit test uses =~ — Bash regex covers pattern matching when validation grows beyond simple ranges:

bash
validate_port() {
  local port=$1
  [[ "$port" =~ ^[0-9]+$ ]] &&
    (( 10#$port >= 1 && 10#$port <= 65535 ))
}

The 10# prefix forces decimal interpretation so values such as 08 are not read as invalid octal. Test a valid port and read $? immediately afterward:

bash
validate_port 8080
echo "8080 status=$?"

Sample output:

output
8080 status=0

An invalid port yields a nonzero status:

bash
validate_port abc
echo "abc status=$?"

Sample output:

output
abc status=1

You can test the function directly in an if statement without touching $? first:

bash
if validate_port 22; then echo "port 22 ok"; fi

Sample output:

output
port 22 ok

Status 0 means success in conditionals; any nonzero value means failure. The Bash exit codes guide explains how $?, exit, and pipeline status fit together at the script level.


Return Data Through stdout

To hand a string or number back to the caller, print it and capture stdout with command substitution. Keep human-readable logs on stderr so they do not pollute the data stream — Bash echo vs printf compares safe printing patterns for each stream.

bash
get_primary_ip() {
  hostname -I | awk '{print $1}'
}

Capture the printed address into a variable:

bash
ip=$(get_primary_ip)
echo "ip=$ip"

Sample output on the lab host:

output
ip=10.0.2.15

return answers "did it succeed?" stdout answers "what is the result?" Remember that result=$(myfunc) runs the function in command-substitution context, so variable changes made inside that call do not persist in the parent shell. Mixing log lines into stdout breaks capture, which is why the log helper at the top of this article writes to stderr.


Return Data with a Nameref

Bash 4.3 and later support namerefs with local -n. The function receives the name of a caller variable and writes through it, without command substitution.

bash
get_user() {
  local -n result="$1"
  result="alice"
}

Pass the name of an empty variable. The caller must pass a valid variable name:

bash
name=
get_user name
echo "$name"

Sample output:

output
alice

local -n creates a nameref, so assignments inside the function modify the variable whose name the caller passed. Use this when the function needs to update caller state and stdout capture is awkward. For read-only data, stdout plus $(...) is usually simpler.


Bash Uses Dynamic Scope

Bash local variables use dynamic scope, not lexical scope. A function can see a caller's local variable while that caller is active, even if the two functions are defined in different parts of the script.

That behavior also affects assignments. If a caller already has local x, an assignment to x inside a nested function can modify the caller's binding instead of a global variable.

This differs from the lexical scoping used by many general-purpose programming languages.

Run a caller and callee pair to see the effect:

bash
caller_fn() {
  local token=secret
  callee_fn
}

callee_fn() {
  echo "callee sees token=$token"
}

caller_fn

Sample output:

output
callee sees token=secret

callee_fn never declares token, so Bash resolves the name in the calling function's scope. If callee_fn added local token=other, it would shadow the caller's binding for its own body only.

Name helper functions carefully when they rely on caller locals. Prefer explicit arguments over hidden dynamic bindings when the data flow is not obvious.


Functions and Subshells

A normal function call runs in the current shell. Variables the function changes remain visible to the caller unless they were declared local in an outer function on the dynamic scope chain.

Parentheses force a subshell. Assignments there do not persist after the subshell exits:

bash
counter=0
bump() { counter=$((counter + 1)); echo "counter=$counter"; }
bump
( bump )
echo "parent counter=$counter"

Sample output:

output
counter=1
counter=2
parent counter=1

The parenthesized call printed counter=2 inside the subshell, but the parent's counter stayed 1.

Command substitution runs its command in a subshell as well. You still see printed output, but variable assignments inside the substitution do not persist in the parent shell:

bash
sub_out=$(bump)
echo "capture out=$sub_out parent counter=$counter"

Sample output:

output
capture out=counter=2 parent counter=1

The capture printed counter=2 from inside the subshell, while the parent's counter remained 1. Treat both ( ... ) and $( ... ) as subshell contexts when you reason about scope.


Recursive Functions and FUNCNEST

A function may call itself, but deep recursion is rare in shell scripts. Loops usually read clearer and run with less stack pressure.

A short countdown shows the idea:

bash
countdown() {
  local n=$1
  echo "n=$n"
  (( n > 0 )) && countdown $((n - 1))
}

countdown 3

Sample output:

output
n=3
n=2
n=1
n=0

Bash can cap function nesting depth with FUNCNEST. It applies to nested function invocations generally, not only direct self-recursion; the manual calls it the maximum function nesting level. Recursion is the most obvious way to hit that limit. When the limit is exceeded, Bash aborts the excessive function call and prints an error:

bash
FUNCNEST=4
nest() {
  echo "depth=${#FUNCNAME[@]}"
  nest
}
nest

Example output:

output
depth=1
depth=2
depth=3
depth=4
nest: maximum function nesting level exceeded (4)

The final diagnostic may include a script name or line number depending on how you run the snippet.

Reach for iteration first. Reserve recursion for tiny depth bounds you control.


Practical Function Library Example

The helpers below combine logging, validation, and stdout data return in one small library. Save them in lib.sh and load with the Bash source command when you reuse the same helpers across scripts, or paste them together and call deploy:

bash
log() {
  printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2
}

validate_port() {
  local port=$1
  [[ "$port" =~ ^[0-9]+$ ]] &&
    (( 10#$port >= 1 && 10#$port <= 65535 ))
}

deploy() {
  local service=$1 port=$2
  log "deploy $service"
  validate_port "$port" || { log "bad port $port"; return 1; }
  echo "started $service on $port"
}

Deploy a valid service and port:

bash
deploy api 8080

Sample output:

output
[23:31:10] deploy api
started api on 8080

A bad port returns failure without starting the service:

bash
deploy api bad || echo "deploy failed status=$?"

Sample output:

output
[23:31:10] deploy api
[23:31:10] bad port bad
deploy failed status=1

Each function has one job: log writes diagnostics, validate_port answers yes or no with status, and deploy orchestrates the steps.


Function Design Best Practices

Reusable functions are easier to maintain when argument handling, variable scope, and output streams are predictable. Keep these habits in mind:

  • Give each function one clear responsibility and a name that matches it.
  • Quote "$1" and use "$@" when forwarding user or path input.
  • Validate $# before you read positional parameters when an argument is required.
  • Declare scratch names with local; pass data explicitly instead of relying on dynamic scope unless the pattern is intentional.
  • Use return for boolean or status results; print data to stdout, use namerefs when a function intentionally needs to write into a caller-provided variable, and send errors to stderr.
  • Prefer loops over recursion unless depth is tiny and bounded.

Preserve command exit status when using local

Combining local and command substitution in one statement can hide the substituted command's exit status. Bash reports the status of the simple command as a whole, and local itself succeeds even when the inner command fails. ShellCheck rule SC2155 flags this pattern — Bash command substitution walks through the same trap with subshell and quoting detail.

bash
fail_cmd() { return 7; }

mask_fail() {
  local x=$(fail_cmd)
  echo "masked status=$?"
}

mask_fail

Sample output:

output
masked status=0

Split declaration and assignment so $? reflects the inner command:

bash
separate_fail() {
  local x
  x=$(fail_cmd)
  echo "separate status=$?"
}

separate_fail

Sample output:

output
separate status=7

Use the two-line form when you need to branch on command failure inside a function.

Exporting functions with export -f copies definitions into child Bash processes, for example bash -c 'myfunc'. That is an advanced portability and security consideration, not the normal way to structure application APIs. For debugging, FUNCNAME, BASH_LINENO, and the caller builtin can print a short call stack when you need to trace which function called which.


Summary

Bash functions let you name reusable command groups that run in the current shell. You define them with name() { ... }, pass arguments through $1, "$@", and ${10}, and keep temporaries from leaking with local. Functions return success or failure through exit status, either from an explicit return or from the last command in the body.

Return strings and structured data by printing to stdout and capturing with $(...), or pass a variable name through local -n on Bash 4.3 and later. Remember that local follows dynamic scope: a callee can see a caller's locals unless it declares its own binding. Watch subshell contexts from ( ... ) and command substitution, split local from command substitution when failure status matters, and treat recursion as a last resort behind FUNCNEST limits.


References


Frequently Asked Questions

Can Bash functions return strings?

No. return only sets a numeric exit status from 0 to 255. To pass a string or command output back to the caller, print it with echo or printf and capture stdout with command substitution such as result=$(myfunc), or use a nameref output parameter with local -n.

What is the difference between return and exit?

return ends the current function and sets its status for the caller. It can also terminate a script that was loaded with source or .. Outside a function or sourced script, return fails. exit terminates the current shell or script process. Using exit inside a function quits the whole program.

Are Bash local variables truly local?

They are local to the function that declares them, but Bash resolves names with dynamic scope. A nested function can read a caller's local variables unless it declares its own binding with local. That is different from lexical scoping in many other languages.

Why does local x=$(command) hide a failure?

The assignment runs as one simple command. The exit status you read immediately afterward reflects that whole command, and local succeeds even when the substituted command fails. Declare with local x first, then assign x=$(command), and test $? before moving on.

Do Bash functions run in a subshell?

Not by default. A normal function call shares the caller's shell environment. Subshells appear when you wrap a call in parentheses or capture output with $(...), which matters for variable persistence and side effects.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)