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.

Style 1 — function keyword
function greet() { echo "Hello, World!" } # Call it — just use the name greet
Style 2 — POSIX (no keyword)
greet() { echo "Hello, World!" } # Call it — same way greet
Style 2 (no keyword) is preferred for portability — it works in bash, zsh, ksh, and any POSIX-compliant shell. Style 1 with the function keyword is bash-specific but makes functions visually obvious when scanning a file. In bash-only scripts, either is fine.
🐧 Functions must be defined before they are called
#!/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
A popular convention is to wrap the main script logic in a 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.

🐧 Passing and accessing arguments
#!/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
The script's own positional parameters ($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.

🐧 The danger of globals — and how local fixes it
#!/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
⚠️ Always declare local variables with 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.
🐧 local — declaration forms
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 $?.

🐧 Using return for pass/fail results
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.

🐧 Returning data values via echo
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
Be careful with the echo-return pattern: any 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.

🐧 Returning via a nameref variable
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
Namerefs avoid the performance cost of a subshell — useful in tight loops. Avoid naming the nameref variable the same as the caller's variable (e.g. don't use -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.

🐧 Factorial and directory tree recursion
# 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.

🐧 Creating and sourcing a library file
# ── 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 vs .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

Guard clauses inside a function
Checking arguments at the top of a function and returning early keeps the main logic uncluttered.
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

Using functions as callbacks via export -f
A function can be exported so it is available in child processes — useful when using 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

Checking whether a function exists
# 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

SyntaxWhat it does
name() { … }Define a function (POSIX style)
function name() { … }Define a function (bash style)
name arg1 arg2Call a function with arguments
$1 $2 … $# $@Function's own positional parameters
local var=valueDeclare a variable local to the function
local -r var=valueLocal read-only variable
local -i var=0Local integer variable
local -a arr=()Local array variable
local -n ref="$1"Nameref — reference to caller's variable (bash 4.3+)
return NExit function with status N (0 = success)
result=$(fn arg)Capture function's printed output as a value
source file or . fileLoad and execute a file in the current shell
export -f nameExport function to child processes
declare -f nameCheck if a function is defined (exit 0 if yes)
declare -FList 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.

Exercise 1
Create a shared library file 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.
Hint: use 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".
Sample Solution — 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" "$@" }
Sample Solution — app.sh
#!/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
Exercise 2
Write a script called 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.
Hint: for 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.
Sample Solution
#!/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'
Exercise 3
Write a script called 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.
Hint: the recursive version is the classic 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.
Sample Solution
#!/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.

Exercise 4
Build a small script called 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.
Hint: call each action function from the case branch inside 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.
Sample Solution
#!/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