Bash Functions
Learn how to define functions, use local variables, handle return codes, pass arguments, and build reusable script libraries.
Defining Functions
Functions let you name a block of code, reuse it in multiple places, and keep your script organized. Without functions, any logic you need more than once has to be copied — and when a bug is found, it has to be fixed in every copy. Functions solve this by giving repeated logic a single home.
Two syntax forms exist — both are valid, but the POSIX form (without the function keyword) is more widely used:
# Form 1: function keyword
function greet() {
echo "Hello, $1!"
}
# Form 2: POSIX-compatible (preferred in most style guides)
greet() {
echo "Hello, $1!"
}
# Call it
greet "Alice" # Hello, Alice!
greet "World" # Hello, World!
Functions must be defined before they are called. The standard pattern is to define all functions first, then call main "$@" at the end of the script:
#!/usr/bin/env bash
set -euo pipefail
log() { echo "[$(date '+%H:%M:%S')] $*"; }
setup() {
log "Setting up environment..."
}
run() {
log "Running task..."
}
main() {
setup
run
log "Done."
}
# Entry point — all functions are defined above before this runs
main "$@"
Passing Arguments
Functions receive arguments exactly like scripts — via $1, $2, $@, and $#. Inside a function, these variables refer to the function’s own arguments, not the script’s top-level arguments. This isolation is intentional and important.
create_user() {
local username="$1"
local group="${2:-users}" # default to "users" if second argument is not provided
local home_dir="/home/$username"
echo "Creating user: $username (group: $group, home: $home_dir)"
# useradd -m -g "$group" -d "$home_dir" "$username"
}
create_user "alice" # Creates alice in group users
create_user "bob" "developers" # Creates bob in group developers
Local Variables
Always use local for variables inside functions. Without it, every variable you set inside a function is global and can silently overwrite a same-named variable in the calling scope — a category of bug that is very hard to trace.
# Bad — x leaks into global scope and overwrites any existing $x
calculate() {
x=42
echo "$x"
}
calculate
echo "$x" # 42 — leaked into the calling scope!
# Good — x is local to the function
calculate() {
local x=42
echo "$x"
}
calculate
echo "${x:-not set}" # "not set" — global scope is clean
Local arrays work the same way:
process_items() {
local -a items=("$@") # local indexed array
local result=""
for item in "${items[@]}"; do
result+="${item},"
done
echo "${result%,}" # strip trailing comma before returning
}
output=$(process_items "a" "b" "c")
echo "$output" # a,b,c
Return Codes
return sets the function’s exit code (0 = success, 1–255 = failure). It does not return a value the way return does in Python or JavaScript. The exit code is how functions signal success or failure to the caller, enabling if myfunc; then patterns.
is_even() {
local n="$1"
(( n % 2 == 0 )) # the (( )) expression sets exit code: 0 if true, 1 if false
}
is_even 4 && echo "4 is even" # prints
is_even 7 && echo "7 is even" # doesn't print
# Explicit return with a meaningful message on failure
validate_port() {
local port="$1"
if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "Invalid port: $port" >&2
return 1
fi
return 0 # success — port is valid
}
if validate_port "8080"; then
echo "Port valid"
fi
if ! validate_port "99999"; then
echo "Port invalid — using default 80"
fi
Returning Values
Because return only carries an integer exit code, returning actual data from a function requires a different strategy. Three patterns cover the common cases:
# Pattern 1: echo + command substitution (most common, cleanest for strings)
get_timestamp() {
date '+%Y%m%d_%H%M%S'
}
ts=$(get_timestamp)
echo "Backup file: backup_${ts}.tar.gz"
# Pattern 2: nameref variable (avoids subshell — required for arrays)
get_user_home() {
local username="$1"
local -n _result="$2" # nameref — _result is an alias for the caller's variable
_result=$(getent passwd "$username" | cut -d: -f6)
}
declare home
get_user_home "root" home
echo "Root home: $home" # /root
# Pattern 3: global variable (simple but less safe — name collisions are possible)
RESULT=""
get_count() {
RESULT=$(wc -l < "$1")
}
get_count /etc/passwd
echo "Lines: $RESULT"
Pattern 1 is cleanest for single string values. Pattern 2 (nameref) avoids spawning a subshell, which matters when you need to return multiple values or arrays.
Functions with Cleanup (trap)
Functions that allocate resources — temp files, locks, open connections — should clean up after themselves even when they exit early due to an error. trap ... RETURN runs a cleanup handler whenever the function returns, regardless of the reason.
run_with_tempfile() {
local tmpfile
tmpfile=$(mktemp)
# Register cleanup before doing any work
trap "rm -f '$tmpfile'" RETURN
echo "Working data" > "$tmpfile"
process_data "$tmpfile"
# tmpfile is automatically deleted when the function returns
}
Building Script Libraries
For larger projects where multiple scripts share common utilities, put shared functions in a library file and source it. This gives you a single place to maintain logging, error handling, and utility functions.
# lib/logging.sh
#!/usr/bin/env bash
# Reusable logging library — source this file to use these functions
readonly LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
LOG_LEVEL="${LOG_LEVEL:-INFO}" # can be overridden by the calling script
log() {
local level="$1"; shift
local message="$*"
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
# Only print if this level meets the configured threshold
if (( LOG_LEVELS[$level] >= LOG_LEVELS[$LOG_LEVEL] )); then
printf '[%s] [%-5s] %s\n' "$ts" "$level" "$message" >&2
fi
}
log_debug() { log DEBUG "$@"; }
log_info() { log INFO "$@"; }
log_warn() { log WARN "$@"; }
log_error() { log ERROR "$@"; }
# main_script.sh
#!/usr/bin/env bash
set -euo pipefail
# BASH_SOURCE[0] is the path of this file — reliable even when sourced
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/logging.sh"
log_info "Starting deployment"
log_debug "Debug details here" # only printed if LOG_LEVEL=DEBUG
log_error "Something went wrong"
Recursive Functions
Bash supports recursion, though it is rarely needed in practice and has performance costs because each $() call spawns a subshell:
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)" # 5! = 120
For deep recursion or performance-sensitive code, prefer iteration. Bash has no tail-call optimization.
What’s Next
The next tutorial covers string manipulation — parameter expansion, substrings, replacements, case conversion, and heredocs.