Bash · lesson 7 of 22
Bash Functions
Learn how to define functions, use local variables, handle return codes, pass arguments, and build reusable script libraries.
By SkillByExample · Updated
Every example below is a complete script, run with bash 5.2 on Ubuntu 24.04.
Defining and Calling Functions
name() { ...; } defines a function. The function name { ...; } form also works, but
it isn’t POSIX. Bash reads a script from top to bottom, so the common layout is to define
everything first, then call main "$@" on the last line.
set -euo pipefail
log() { echo "[$(date -u '+%H:%M:%S')] $*"; }
setup() { log "Setting up for $1"; }
run() { log "Running with $# args: $*"; }
main() {
setup "$1"
run "$@"
log "Done."
}
main "$@"
$ bash main.sh prod --verbose
[13:12:43] Setting up for prod
[13:12:43] Running with 2 args: prod --verbose
[13:12:43] Done.
main "$@" hands the script’s arguments to main, and main passes on only what each
helper needs. Since every function exists by the time the last line runs, their order in
the file doesn’t matter.
Arguments
Inside a function, $1, $@ and $# are the function’s arguments. $0 is still the
script name.
create_user() {
local username="$1"
local group="${2:-users}"
echo "user=$username group=$group (function sees $# args, \$0=$0)"
}
echo "script got: $*"
create_user "alice"
create_user "bob" "developers"
$ bash args.sh ignored-by-functions
script got: ignored-by-functions
user=alice group=users (function sees 1 args, $0=args.sh)
user=bob group=developers (function sees 2 args, $0=args.sh)
The script’s own argument never reached create_user. A function only sees what it’s
called with. Give arguments names with local in the first lines. $3 tells a reader
nothing six lines later.
Local Variables and Dynamic Scope
Without local, a variable assigned in a function is global.
x=1
leaky() { x=42; }
careful() { local x=99; }
careful; echo "after careful: x=$x"
leaky; echo "after leaky: x=$x"
outer() { local secret="outer's"; inner; }
inner() { echo "inner sees: ${secret:-nothing}"; }
outer
inner
$ bash scope.sh
after careful: x=1
after leaky: x=42
inner sees: outer's
inner sees: nothing
The last two lines surprise people coming from other languages. local is dynamically
scoped, so a function called from outer can see outer’s locals. Called on its own,
inner sees nothing. Don’t rely on this to pass data around. Pass arguments instead.
Return Codes
return sets an exit status from 0 to 255, like any command’s. Without return, a
function’s status is that of the last command it ran.
is_even() { (( $1 % 2 == 0 )); }
is_even 4 && echo "4 is even"
is_even 7 || echo "7 is odd"
code() { return "$1"; }
for n in 0 1 255 256 300; do
code "$n"; echo "return $n -> \$? = $?"
done
$ bash return.sh
4 is even
7 is odd
return 0 -> $? = 0
return 1 -> $? = 1
return 255 -> $? = 255
return 256 -> $? = 0
return 300 -> $? = 44
Values wrap modulo 256, so return 256 reports success. Never use return to pass
back a count or an ID. It is only for signalling success or failure. A typical validator
puts its message on stderr and returns 1:
validate_port() {
local port="$1"
if [[ ! $port =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "Invalid port: $port" >&2
return 1
fi
}
for p in 8080 99999 http; do
if validate_port "$p"; then echo "$p ok"; else echo "$p rejected"; fi
done
$ bash validate.sh
8080 ok
Invalid port: 99999
99999 rejected
Invalid port: http
http rejected
The regex runs first, so http is rejected before (( )) could try to evaluate it as a
variable name.
Returning Data
There are three ways to get data out of a function, and they don’t all behave the same.
get_stamp() { printf '%s' "backup_$(date -u +%Y)"; }
name=$(get_stamp)
echo "1) echo+capture: $name.tar.gz"
split_host() {
local -n _host="$2" _port="$3"
_host="${1%:*}"
_port="${1##*:}"
}
split_host "db.internal:5432" host port
echo "2) nameref: host=$host port=$port"
counter=0
bump_in_subshell() { counter=$((counter + 1)); echo "$counter"; }
out=$(bump_in_subshell)
echo "3) captured '$out' but counter is still $counter"
$ bash values.sh
1) echo+capture: backup_2026.tar.gz
2) nameref: host=db.internal port=5432
3) captured '1' but counter is still 0
Echoing and capturing with $(...) is the cleanest way to return one string. A nameref
(local -n) writes straight into the caller’s variables, which is the way to return
several values. Line 3 shows the catch with $(...): it runs the function in a
subshell, so any global the function changes is thrown away along with it. A function
you call with $(...) can only communicate through its output.
The local var=$(cmd) Trap
set -e and $? both miss one very common failure:
set -e
fetch() { return 3; }
check() {
local data=$(fetch)
echo "local+assign status: $?"
local data2
data2=$(fetch)
echo "never reached"
}
check
$ bash masked.sh
local+assign status: 0
local data=$(fetch) reports the status of the local builtin, which succeeded, so
fetch’s status 3 was lost. After the split into local data2 and data2=$(fetch), the
failure is visible. set -e stopped the script there with status 3, and “never reached”
wasn’t printed. ShellCheck flags the combined form as SC2155.
Cleanup with trap ... RETURN
A RETURN trap runs when the function returns, which is a good place to delete temporary
files.
work() {
local tmp
tmp=$(mktemp)
trap 'rm -f "$tmp"; echo "cleaned $tmp"' RETURN
echo "data" > "$tmp"
echo "working in $tmp"
return 0
}
work | sed 's#/tmp/tmp\.[A-Za-z0-9]*#/tmp/tmp.XXXX#'
ls /tmp | grep -c '^tmp\.' || true
$ bash trap-return.sh
working in /tmp/tmp.XXXX
cleaned /tmp/tmp.XXXX
0
The sed only hides the random filename. The final 0 confirms that no tmp.* file was
left behind. For whole-script cleanup that also runs on errors, use trap ... EXIT
instead (see the error-handling lesson).
Script Libraries and a Real Bug
Shared helpers go in a file that each script sources. Here is a logging library as it
often appears in tutorials:
readonly LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
LOG_LEVEL="${LOG_LEVEL:-INFO}"
log() {
local level="$1"; shift
if (( LOG_LEVELS[$level] >= LOG_LEVELS[$LOG_LEVEL] )); then
printf '[%-5s] %s\n' "$level" "$*"
fi
}
log DEBUG "debug detail"
log INFO "starting"
declare -p LOG_LEVELS
$ bash logging.sh
[DEBUG] debug detail
[INFO ] starting
declare -ar LOG_LEVELS=([0]="3")
The threshold is INFO, but DEBUG printed anyway. declare -p shows why: without -A,
readonly created an indexed array. Every key collapsed to index 0, and the last value
(3) won. Every level therefore compares 3 against 3, and everything prints. The fix is to
declare it associative:
declare -rA LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
LOG_LEVEL="${LOG_LEVEL:-INFO}"
log() {
local level="$1"; shift
if (( LOG_LEVELS[$level] >= LOG_LEVELS[$LOG_LEVEL] )); then
printf '[%-5s] %s\n' "$level" "$*"
fi
}
log DEBUG "debug detail"
log INFO "starting"
log ERROR "disk full"
$ bash logging-fixed.sh
[INFO ] starting
[ERROR] disk full
To use it as a library, save the definitions as lib/logging.sh. Load it with
source "$(dirname "${BASH_SOURCE[0]}")/lib/logging.sh". Using BASH_SOURCE resolves the
path relative to the script file, not to whatever directory the caller happens to be in.
Recursion
Bash supports recursion, but each level here runs in a $(...) subshell, and the
arithmetic is 64-bit signed:
factorial() {
local n="$1"
if (( n <= 1 )); then echo 1; return; fi
local sub
sub=$(factorial $(( n - 1 )))
echo $(( n * sub ))
}
echo "5! = $(factorial 5)"
echo "20! = $(factorial 20)"
echo "21! = $(factorial 21)"
$ bash factorial.sh
5! = 120
20! = 2432902008176640000
21! = -4249290049419214848
21! overflows 64 bits and wraps to a negative number without any warning. Bash never
reports overflow, so use bc or another language once numbers get this large.
Practice
-
Write
maxso that it echoes the largest of its arguments. It must also work with a single argument.Solution
max() { local best="$1" n shift for n in "$@"; do (( n > best )) && best=$n done echo "$best" } echo "max: $(max 3 17 -2 9)" echo "max of one: $(max 5)"$ bash p1.sh max: 17 max of one: 5 -
Write
require_cmdso that it takes several command names, reports every missing one on stderr, and returns 1 if any are missing.Solution
require_cmd() { local c missing=0 for c in "$@"; do if ! command -v "$c" >/dev/null; then echo "missing required command: $c" >&2 missing=1 fi done return "$missing" } require_cmd jq curl && echo "jq and curl present" require_cmd jq nosuchtool || echo "cannot continue"$ bash p2.sh jq and curl present missing required command: nosuchtool cannot continueIt checks every name before returning, so the user sees the whole list of missing commands at once, not just the first.
-
Rewrite
checkfrommasked.shso that afetchfailure is reported with its real status and passed on to the caller, while the script keeps running underset -e.Solution
set -e fetch() { return 3; } check() { local data rc data=$(fetch) || { rc=$?; echo "fetch failed with status $rc" >&2; return "$rc"; } echo "got: $data" } check || echo "check returned $?" echo "script still running"$ bash p3.sh fetch failed with status 3 check returned 3 script still runningA command on the left of
||doesn’t triggerset -e, and$?inside the{ }still holdsfetch’s status. -
Rewrite
factorialas a loop so it starts no subshells.Solution
factorial() { local n="$1" result=1 while (( n > 1 )); do (( result *= n, n-- )) done echo "$result" } echo "5! = $(factorial 5)" echo "0! = $(factorial 0)"$ bash p4.sh 5! = 120 0! = 1