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
- Arguments:
$1,$@,$*, and$# "$@"vs"$*"(the quoting matters)- Return values: exit code vs stdout
- Local variables and the global-by-default trap
- Recursion
- Pass-by-reference with
declare -n - Function libraries:
sourceandexport -f - Common pitfalls
- What to do next
- FAQ
The two declaration syntaxes
# 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, worldA 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):
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 cherryTwo surprises:
$0is 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.$10is$1followed by the literal0.
To shift arguments off the front of the list (consume them one at a time):
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.
| Form | Behavior |
|---|---|
"$@" | 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:
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 correctlyWithout 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:
- Exit code via
return N— an integer 0-255 indicating success (0) or failure (non-zero). NOT for data. - stdout via
echo/printf— the way to "return" actual data, captured by the caller with$(...).
# 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-worldThree things that bite people:
returnonly accepts 0-255.return 256wraps to 0;return -1wraps to 255. Treat it as an 8-bit value.- You can't
returna string.return "hello"errors out (or returns 0 ifhelloevaluates to nothing). - Capturing stdout is
$(function_name args), notfunction_name argsalone. 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":
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
fiStdout 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.
# ❌ 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.
# ✅ 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 valuelocal accepts the same initializer syntax as a regular assignment:
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.
factorial() {
local n="$1"
if (( n <= 1 )); then
echo 1
else
echo $(( n * $(factorial $((n - 1))) ))
fi
}
factorial 5 # 120
factorial 10 # 3628800Bash 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:
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.
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 cherryThe 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 .):
# 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"
}# 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:
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:
- How to Export All MySQL Databases — sourced helpers for
notify_slack,rotate_logs, anddump_database. - How to Export and Import PuTTY Settings — wraps
regeditshelling out behind named functions for portability. - How to Increase Google Cloud VM Disk Size Without Rebooting — uses a
wait_for_devicepolling function that gets reused across cloud providers.
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
# 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.shdeclare -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
- Bash if, else, and elif — conditional logic inside functions, exit-code return values, and short-circuit guards.
- Bash Arrays — pass arrays to functions by name with
declare -n, or by value with"$@". - Bash For Loops — iterate inside functions, with
localloop variables. - Bash While Loops — condition-driven loops and the pipe-subshell scoping rule that applies to function bodies too.
- How to Export All MySQL Databases — production example of a Bash function library for backup automation.
- How to Export and Import PuTTY Settings — Bash wrapper functions around Windows
regeditfor cross-platform tooling. - How to Increase Google Cloud VM Disk Size Without Rebooting — reusable polling function for cloud device-readiness checks.
- External: the Bash Reference Manual: Shell Functions is the authoritative spec.



![Bash while loop reference: read files with while IFS= read -r, retry-with-backoff, wait-for-service polling, the subshell-scoping bug fix, the until and select siblings, plus [[ ]] vs [ ] vs (( )) test contexts.](https://techearl.com/cdn-cgi/image/width=1536,format=auto,quality=80/https://images.techearl.com/bash-while-loop/bash-while-loop.jpg?v=2026-04-25T13%3A18%3A00Z)
![Bash conditionals reference: if/elif/else syntax, [ ] vs [[ ]] vs (( )) test contexts, numeric, string, and file operators, the case statement, and the unquoted-variable pitfall.](https://techearl.com/cdn-cgi/image/width=1536,format=auto,quality=80/https://images.techearl.com/bash-if-else/bash-if-else.jpg?v=2026-01-08T11%3A24%3A00Z)
