Skip to main content
Bash beginner Lesson 7 of 22

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.

Frequently Asked Questions

Can Bash functions return values?
Not directly like other languages. return only sets the exit code (0–255). To return a string, either echo the value and capture it with $(), or write to a global variable.
Do I need to declare functions before calling them?
Yes. Bash reads scripts top-to-bottom. Define all functions before the code that calls them, or define a main() function at the bottom and call main "$@" at the very end.
What does local do?
local restricts a variable's scope to the current function and its children. Without local, all variables in a function are global and can clobber variables in the calling scope.