Skip to main content
Bash · lesson 7 of 22
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.

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

  1. Write max so 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
  2. Write require_cmd so 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 continue

    It checks every name before returning, so the user sees the whole list of missing commands at once, not just the first.

  3. Rewrite check from masked.sh so that a fetch failure is reported with its real status and passed on to the caller, while the script keeps running under set -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 running

    A command on the left of || doesn’t trigger set -e, and $? inside the { } still holds fetch’s status.

  4. Rewrite factorial as 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

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.
Why doesn't set -e catch a failure in local var=$(cmd)?
Because the status you get is that of the local builtin, which succeeded, not of the command substitution. Declare the variable first with local var, then assign on a separate line with var=$(cmd), so a failure is visible to set -e and to $?.