| 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 |
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:
log() {
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2
}Call it with a message string:
log "starting backup"Sample output:
[23:31:10] starting backupThe 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:
greet() {
echo "hello"
}name() { ...; } is the portable function syntax and is also the common style in Bash. The function keyword form is Bash-specific:
function greet2 {
echo "hello again"
}Call either form by name:
greet
greet2Sample output:
hello
hello againFor 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:
show_args() {
echo "count=$# first=$1"
}
show_args "one word" twoSample output:
count=2 first=one wordQuoted "$@" preserves each argument as its own word. That matters when an argument contains spaces:
show_words() {
printf 'arg=<%s>\n' "$@"
}
show_words "one word" twoSample 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:
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:
greet Alice
greet
echo "missing-arg status=$?"Sample output:
Hello, Alice
usage: greet NAME
missing-arg status=2When a function returns, Bash restores the caller's positional parameters. Define inner separately so the example teaches one concept at a time:
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 betaSample output:
outer before: 1=alpha 2=beta
inner: 1=inner-only
outer after: 1=alpha 2=betaThe 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:
f_global() { x=2; }
x=1
f_global
echo "x after f_global=$x"Sample output:
x after f_global=2The outer x changed because f_global wrote to the global name. Mark temporaries with local when the value should not leak out:
f_local() {
local x=3
echo "inside x=$x"
}
x=1
f_local
echo "x after f_local=$x"Sample output:
inside x=3
x after f_local=1Treat 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.
is_file() {
[[ -f "$1" ]]
}Test whether a path is a regular file:
if is_file /etc/passwd; then echo "exists"; fiSample output:
existsreturn 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:
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:
validate_port 8080
echo "8080 status=$?"Sample output:
8080 status=0An invalid port yields a nonzero status:
validate_port abc
echo "abc status=$?"Sample output:
abc status=1You can test the function directly in an if statement without touching $? first:
if validate_port 22; then echo "port 22 ok"; fiSample output:
port 22 okStatus 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.
get_primary_ip() {
hostname -I | awk '{print $1}'
}Capture the printed address into a variable:
ip=$(get_primary_ip)
echo "ip=$ip"Sample output on the lab host:
ip=10.0.2.15return 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.
get_user() {
local -n result="$1"
result="alice"
}Pass the name of an empty variable. The caller must pass a valid variable name:
name=
get_user name
echo "$name"Sample output:
alicelocal -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:
caller_fn() {
local token=secret
callee_fn
}
callee_fn() {
echo "callee sees token=$token"
}
caller_fnSample output:
callee sees token=secretcallee_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:
counter=0
bump() { counter=$((counter + 1)); echo "counter=$counter"; }
bump
( bump )
echo "parent counter=$counter"Sample output:
counter=1
counter=2
parent counter=1The 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:
sub_out=$(bump)
echo "capture out=$sub_out parent counter=$counter"Sample output:
capture out=counter=2 parent counter=1The 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:
countdown() {
local n=$1
echo "n=$n"
(( n > 0 )) && countdown $((n - 1))
}
countdown 3Sample output:
n=3
n=2
n=1
n=0Bash 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:
FUNCNEST=4
nest() {
echo "depth=${#FUNCNAME[@]}"
nest
}
nestExample 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:
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:
deploy api 8080Sample output:
[23:31:10] deploy api
started api on 8080A bad port returns failure without starting the service:
deploy api bad || echo "deploy failed status=$?"Sample output:
[23:31:10] deploy api
[23:31:10] bad port bad
deploy failed status=1Each 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
returnfor 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.
fail_cmd() { return 7; }
mask_fail() {
local x=$(fail_cmd)
echo "masked status=$?"
}
mask_failSample output:
masked status=0Split declaration and assignment so $? reflects the inner command:
separate_fail() {
local x
x=$(fail_cmd)
echo "separate status=$?"
}
separate_failSample output:
separate status=7Use 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
- GNU Bash manual — Shell Functions
- GNU Bash manual — Local Variables
- ShellCheck SC2155 — Declare and assign separately
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.

