Functions
🧩 Topic 7 — Functions
Functions let you group commands into a named, reusable block. Instead of copying the same ten lines in three places, you write them once as a function and call it wherever needed. This chapter covers both ways to define a function, how arguments and return values work, the critical importance of local variables, recursive functions, and how to split your code across multiple files using source.
1 — Defining and Calling Functions
There are two syntactically equivalent ways to define a function in bash. Both are in common use — pick one and be consistent.
function greet() {
echo "Hello, World!"
}
# Call it — just use the name
greet
greet() {
echo "Hello, World!"
}
# Call it — same way
greet
function keyword is bash-specific but makes functions visually obvious when scanning a file. In bash-only scripts, either is fine.
#!/bin/bash
# ✔ Define first, then call
say_hello() {
echo "Hello!"
}
say_hello
# ✘ Calling before defining — bash error: command not found
say_goodbye # error here
say_goodbye() { echo "Goodbye!"; }
# Common pattern: define all functions at the top,
# then put the main logic at the bottom.
main() {
say_hello
}
main
main() function and call it at the very end. This lets you define all helper functions above main() without worrying about order.2 — Function Arguments
Inside a function, $1, $2, $@, $#, and $* refer to the function's own arguments, not the script's arguments. This is the same set of positional parameter variables — they are just scoped to the function call.
#!/bin/bash
greet() {
local name="$1"
local title="${2:-Mr/Ms}" # default value if $2 not given
echo "Hello, $title $name!"
}
greet "Philip" "Dr"
Hello, Dr Philip!
greet "Philip"
Hello, Mr/Ms Philip!
─────────────────────────────────────────────────────
print_all() {
echo "Number of args : $#"
echo "All args : $@"
for arg in "$@"; do
echo " - $arg"
done
}
print_all apple "banana split" cherry
Number of args : 3
All args : apple banana split cherry
- apple
- banana split
- cherry
$1, $2, etc.) are still accessible inside a function — they are just shadowed by the function's arguments. To access the original script arguments from within a function, save them to variables before calling the function.3 — Local Variables and Scope
By default, every variable in bash is global — a variable set inside a function is visible everywhere in the script, and can accidentally overwrite a variable with the same name in the calling code. Use local to declare a variable that exists only within the function.
#!/bin/bash
# ── Without local — BAD ───────────────────────────────
double_bad() {
result=$(( $1 * 2 )) # sets the GLOBAL variable 'result'
}
result="original"
double_bad 5
echo "$result"
10 # 'original' was silently overwritten!
# ── With local — GOOD ─────────────────────────────────
double_good() {
local result=$(( $1 * 2 )) # only exists inside this function
echo "$result"
}
result="original"
double_good 5
10
echo "$result"
original # global is untouched
local. This is the single most important function-writing habit in bash. Failing to use local is a common source of subtle, hard-to-debug bugs where a helper function silently corrupts a variable in the caller.
example() {
local name="Philip" # local and assigned
local count # local but unset (empty string)
local x=1 y=2 z=3 # multiple on one line
local -r MAX=100 # local AND read-only
local -i total=0 # local integer
local -a items=() # local array
# ...
}
4 — Return Values
Bash functions can "return" in two fundamentally different ways — and which you use depends on whether you need an exit status or an actual data value.
Method 1 — return (exit status only)
return N sets the function's exit status to N (0–255). Like a command's exit status, 0 means success and non-zero means failure. The caller reads it via $?.
is_even() {
local n="$1"
(( n % 2 == 0 )) # (( )) sets exit status: 0 if true, 1 if false
# no explicit return needed — last command's status is used
}
if is_even 4; then
echo "4 is even"
fi
4 is even
if ! is_even 7; then
echo "7 is odd"
fi
7 is odd
─────────────────────────────────────────────────────
validate_age() {
local age="$1"
[[ "$age" =~ ^[0-9]+$ ]] || return 1 # not numeric
(( age >= 1 && age <= 120 )) # in valid range
}
validate_age "25" && echo "Valid" || echo "Invalid"
Valid
validate_age "abc" && echo "Valid" || echo "Invalid"
Invalid
Method 2 — echo (capture output)
To return an actual string or number, echo it from the function and capture it with command substitution. This is the standard way to "return a value" in bash.
to_upper() {
echo "${1^^}"
}
result=$(to_upper "hello world")
echo "$result"
HELLO WORLD
─────────────────────────────────────────────────────
add() {
echo $(( $1 + $2 ))
}
sum=$(add 15 27)
echo "Sum: $sum"
Sum: 42
─────────────────────────────────────────────────────
# You can use BOTH at the same time:
# echo the data value AND set the exit status
safe_divide() {
local a="$1" b="$2"
if (( b == 0 )); then
echo "ERROR: division by zero" >&2
return 1
fi
echo "scale=4; $a / $b" | bc
}
if val=$(safe_divide 10 3); then
echo "Result: $val"
else
echo "Calculation failed."
fi
Result: 3.3333
echo or printf inside the function becomes part of its "return value" when captured. Use echo "..." >&2 for debug output you do not want captured.Method 3 — nameref (bash 4.3+)
A nameref variable (local -n) is a reference to another variable by name. It lets a function write a result into a caller-supplied variable name — avoiding a subshell entirely.
repeat_str() {
local -n _out="$1" # -n makes _out a reference to the variable named by $1
local str="$2"
local n="$3"
_out=""
for (( i=0; i<n; i++ )); do
_out+="$str"
done
}
repeat_str my_result "ab" 4
echo "$my_result"
abababab
-n result if the caller also has a result variable) — it causes a circular reference.5 — Recursive Functions
A function can call itself — this is called recursion. Each call gets its own local variable scope, so the variables from one level don't interfere with another. Bash supports recursion but has no tail-call optimisation, so deep recursion is slow and risks hitting stack limits. Keep recursive depths shallow.
# Classic recursive factorial
factorial() {
local n="$1"
(( n <= 1 )) && { echo 1; return; }
local prev=$(factorial $(( n - 1 )))
echo $(( n * prev ))
}
echo "5! = $(factorial 5)"
5! = 120
echo "10! = $(factorial 10)"
10! = 3628800
─────────────────────────────────────────────────────
# Recursive directory listing with indentation
list_tree() {
local dir="$1"
local indent="$2"
local item
for item in "$dir"/*; do
[[ -e "$item" ]] || continue
echo "${indent}$(basename "$item")"
[[ -d "$item" ]] && list_tree "$item" "${indent} "
done
}
list_tree /etc/ssh ""
6 — Function Libraries and source
Once you have a collection of useful functions, you can save them in a separate file and load them into any script using source (or its shorthand .). This is how shared utility libraries work in bash.
# ── lib/utils.sh — the shared library ────────────────
#!/bin/bash
# Guard against being sourced more than once
[[ -n "${_UTILS_LOADED:-}" ]] && return
readonly _UTILS_LOADED=1
log_info() {
printf "[INFO] %s %s\n" "$(date +%H:%M:%S)" "$*"
}
log_warn() {
printf "[WARN] %s %s\n" "$(date +%H:%M:%S)" "$*" >&2
}
log_error() {
printf "[ERROR] %s %s\n" "$(date +%H:%M:%S)" "$*" >&2
}
die() {
log_error "$1"
exit "${2:-1}"
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || \
die "Required command not found: $1"
}
# ── myscript.sh — loads the library ──────────────────
#!/bin/bash
# Get the directory of this script, then source the library
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib/utils.sh
source "$SCRIPT_DIR/lib/utils.sh"
require_cmd curl
log_info "Starting backup..."
log_warn "Disk space is low."
log_info "Done."
[INFO] 10:32:45 Starting backup...
[WARN] 10:32:45 Disk space is low.
[INFO] 10:32:45 Done.
${BASH_SOURCE[0]} gives the path to the current file even when it is sourced — unlike $0 which gives the top-level script's name. Always use BASH_SOURCE in library files.source file and . file are identical. Both run the file in the current shell (not a subshell), so any functions and variables defined in it become available immediately. The dot form is POSIX-standard; source is bash-specific but more readable.
7 — Advanced Patterns
Functions that validate arguments
create_backup() {
local src="$1"
local dest="$2"
[[ -n "$src" ]] || { log_error "src required"; return 1; }
[[ -n "$dest" ]] || { log_error "dest required"; return 1; }
[[ -e "$src" ]] || { log_error "src does not exist: $src"; return 1; }
[[ -d "$dest" ]] || mkdir -p "$dest"
cp -r "$src" "$dest/" && log_info "Backed up $src to $dest"
}
Storing and passing functions
xargs or parallel.process_file() {
echo "Processing: $1 (size: $(wc -c < "$1") bytes)"
}
# Export the function so subshells can see it
export -f process_file
# Run it via xargs — each file is processed in a subshell
find /tmp -name "*.log" | xargs -I{} bash -c 'process_file "$@"' _ {}
Using command to call a function safely
# Check if a function is defined before calling it
if declare -f my_function >/dev/null; then
my_function
else
echo "my_function is not defined"
fi
# List all defined functions
declare -F # prints: declare -f function_name for each
declare -F | awk '{print $3}' # just the names
8 — Quick Reference
| Syntax | What it does |
|---|---|
name() { … } | Define a function (POSIX style) |
function name() { … } | Define a function (bash style) |
name arg1 arg2 | Call a function with arguments |
$1 $2 … $# $@ | Function's own positional parameters |
local var=value | Declare a variable local to the function |
local -r var=value | Local read-only variable |
local -i var=0 | Local integer variable |
local -a arr=() | Local array variable |
local -n ref="$1" | Nameref — reference to caller's variable (bash 4.3+) |
return N | Exit function with status N (0 = success) |
result=$(fn arg) | Capture function's printed output as a value |
source file or . file | Load and execute a file in the current shell |
export -f name | Export function to child processes |
declare -f name | Check if a function is defined (exit 0 if yes) |
declare -F | List all currently defined function names |
${BASH_SOURCE[0]} | Path to the current file (works even when sourced) |
✏️ Exercises
Apply what you have learned in this chapter. Try each exercise yourself before looking at the sample solution.
lib/log.sh that defines four logging functions: log_info, log_warn, log_error, and log_debug. Each should print a timestamp, a level label, and the message. Then write a script app.sh that sources the library and calls each function. Add a global variable LOG_LEVEL (default INFO) that suppresses log_debug output unless LOG_LEVEL=DEBUG is set.printf "[%-5s] %s %s\n" for consistent label width. In log_debug, check [[ "${LOG_LEVEL:-INFO}" == "DEBUG" ]] before printing. Source with source "$(dirname "$0")/lib/log.sh".#!/bin/bash
# lib/log.sh
[[ -n "${_LOG_LOADED:-}" ]] && return
readonly _LOG_LOADED=1
_log() {
local level="$1"; shift
printf "[%-5s] %s %s\n" "$level" "$(date +%H:%M:%S)" "$*"
}
log_info() { _log "INFO" "$@"; }
log_warn() { _log "WARN" "$@" >&2; }
log_error() { _log "ERROR" "$@" >&2; }
log_debug() {
[[ "${LOG_LEVEL:-INFO}" == "DEBUG" ]] || return 0
_log "DEBUG" "$@"
}
#!/bin/bash
# app.sh
source "$(dirname "$0")/lib/log.sh"
log_info "Application starting."
log_debug "Debug detail hidden by default."
log_warn "Disk space is below 20%%."
log_error "Could not connect to database."
# Run with: LOG_LEVEL=DEBUG ./app.sh to see debug output
string_utils.sh that defines three functions: str_repeat (repeat a string N times), str_pad (pad a string to a given width with a pad character), and str_trim (remove leading and trailing whitespace). Each function should print its result so it can be captured with $( ). Include test calls at the bottom demonstrating each function.str_repeat use a C-style for loop appending to a local variable. For str_pad use printf "%-Ns" with a calculated width. For str_trim use parameter expansion: ${var#"${var%%[![:space:]]*}"} strips the leading spaces.#!/bin/bash
# string_utils.sh
str_repeat() {
local str="$1" n="$2" out=""
for (( i=0; i<n; i++ )); do out+="$str"; done
echo "$out"
}
str_pad() {
# str_pad "text" width [pad_char]
local str="$1" width="$2" pad="${3:- }"
local len=${#str}
local padding=""
for (( i=len; i<width; i++ )); do padding+="$pad"; done
echo "${str}${padding}"
}
str_trim() {
local str="$1"
# strip leading whitespace
str="${str#"${str%%[![:space:]]*}"}"
# strip trailing whitespace
str="${str%"${str##*[![:space:]]}"}"
echo "$str"
}
# ── Test calls ───────────────────────────────────────
echo "repeat : $(str_repeat "ab" 5)"
repeat : ababababab
echo "pad : '$(str_pad "hello" 12 ".")'"
pad : 'hello.......'
echo "trim : '$(str_trim " hello world ")'"
trim : 'hello world'
fibonacci.sh that uses a recursive function to calculate the Nth Fibonacci number. Then add a second, iterative version of the same function and compare their outputs. Call both with the same input (try N=10 and N=15) and print the results side by side.fib(n) = fib(n-1) + fib(n-2) with base cases 0 and 1. For the iterative version, use a while loop with two tracking variables a and b, swapping values each iteration.#!/bin/bash
# fibonacci.sh
fib_recursive() {
local n="$1"
(( n <= 1 )) && { echo "$n"; return; }
local a=$(fib_recursive $(( n-1 )))
local b=$(fib_recursive $(( n-2 )))
echo $(( a + b ))
}
fib_iterative() {
local n="$1" a=0 b=1 tmp
(( n == 0 )) && { echo 0; return; }
for (( i=1; i<n; i++ )); do
tmp=$(( a + b ))
a=$b
b=$tmp
done
echo "$b"
}
printf "%-5s %12s %12s\n" "N" "Recursive" "Iterative"
printf '%.0s─' {1..32}; echo
for n in 0 1 5 10 15; do
printf "%-5s %12s %12s\n" "$n" \
"$(fib_recursive "$n")" \
"$(fib_iterative "$n")"
done
Notice how the recursive version gets progressively slower for larger N — each call spawns a subshell. The iterative version stays fast because it uses only arithmetic. For N≥20, always prefer the iterative approach.
menu_app.sh that uses functions to structure a multi-option interactive application. Define separate functions for at least three actions (e.g. show_system_info, show_disk_usage, show_top_processes), a show_menu function using select, and a main function that calls show_menu in a loop. The menu should include a "Quit" option that exits cleanly.show_menu. In main, use while true; do show_menu; done. Have the "Quit" branch call exit 0 or set a flag variable that causes main to break.#!/bin/bash
# menu_app.sh
show_system_info() {
echo "── System Info ──────────────────"
printf "Host : %s\n" "$(hostname)"
printf "User : %s\n" "$USER"
printf "Uptime : %s\n" "$(uptime -p)"
printf "Shell : %s\n" "$SHELL"
echo
}
show_disk_usage() {
echo "── Disk Usage ───────────────────"
df -h --output=target,size,used,avail,pcent | head -6
echo
}
show_top_processes() {
echo "── Top 5 Processes (by CPU) ─────"
ps aux --sort=-%cpu | awk 'NR==1 || NR<=6 {printf "%-20s %5s %5s\n", $11, $3, $4}'
echo
}
show_menu() {
PS3="Choose an option: "
select choice in "System Info" "Disk Usage" "Top Processes" "Quit"; do
case "$choice" in
"System Info") show_system_info; break ;;
"Disk Usage") show_disk_usage; break ;;
"Top Processes") show_top_processes; break ;;
"Quit") echo "Goodbye!"; exit 0 ;;
*) echo "Invalid option." ;;
esac
done
}
main() {
echo "════════════════════════════"
echo " System Dashboard"
echo "════════════════════════════"
while true; do
show_menu
done
}
main