A shell script with no functions is a shell script that won’t survive its first growth spurt. Once you go past about 50 lines, you start needing structure, and the only structuring primitive shell gives you is the function. The good news: functions in bash are simple, fast (they don’t fork like external commands do), and powerful. The bad news: bash’s default scoping rules are dangerously global, the difference between return and exit is a frequent source of bugs, and the conventions around “returning a value” from a function are not what you’re used to from any other language.
This lesson covers functions completely. By the end, you’ll write shell scripts that look like real programs: a top-level main "$@" line, a half-dozen named functions each with local variables, clean separation between status (return code) and output (stdout), and zero risk of one function silently overwriting another’s state.
1. Defining functions: two equivalent forms
Bash has two function-definition syntaxes:
# POSIX form
greet() {
echo "Hello, $1"
}
# bash-specific form (the `function` keyword is technically optional)
function greet {
echo "Hello, $1"
}
# Mixed form (works in bash but is non-portable; avoid)
function greet() {
echo "Hello, $1"
}
Use the POSIX form (name() { ... }). It works in every shell — bash, zsh, dash, ash, busybox. The function keyword is bash-specific and adds no semantic value, just visual noise.
A function definition is just a parsed, named block of commands. Defining a function does not execute it. You execute it by calling it like any other command:
greet() {
echo "Hello, $1"
}
greet Alice # prints: Hello, Alice
greet Bob # prints: Hello, Bob
Functions live in the current shell’s namespace, alongside aliases and variables. They take precedence over external commands of the same name (with one important exception we’ll see below).
You can list defined functions with declare -F (names only) or declare -f (full source):
declare -F # all defined functions
declare -F greet # confirm `greet` is defined
declare -f greet # show the body
You can undefine a function with unset -f greet.
2. Arguments arrive as positional parameters
When you call greet Alice, bash sets up the same positional-parameter machinery as for the script itself. Inside the function, $1 is Alice, $2 is empty, $# is 1, and so on. The script’s own $1, $2, etc. are shadowed — the function has its own copy.
greet() {
echo "First arg: $1"
echo "Second arg: $2"
echo "All args: $@"
echo "Count: $#"
}
greet Alice Bob Charlie
# First arg: Alice
# Second arg: Bob
# All args: Alice Bob Charlie
# Count: 3
After the function returns, the script’s positional parameters are restored. This is the same scoping behaviour the script gets relative to the parent shell.
The same quoting rules from L2 apply: always use "$@" when forwarding arguments, never unquoted $@ or "$*":
wrap() {
./real-tool "$@" # CORRECT — forwards each arg as a separate quoted token
}
wrap "hello world" "foo" # real-tool sees 2 args: "hello world", "foo"
If you wrote ./real-tool $@ (no quotes), "hello world" would split into two arguments. If you wrote ./real-tool "$*", all args would join into one. Both are wrong for forwarding.
shift — consuming arguments
shift discards $1 and renumbers everything else. $2 becomes $1, $3 becomes $2, and so on. $# decreases.
process_first_three() {
for i in 1 2 3; do
echo "Arg $i: $1"
shift
done
}
process_first_three a b c d e
# Arg 1: a
# Arg 2: b
# Arg 3: c
shift N shifts by N at once. Useful when you want to consume an option and its value:
parse_options() {
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
PORT="$2"
shift 2 # consume both --port and its value
;;
--verbose)
VERBOSE=1
shift # consume just --verbose
;;
*)
echo "Unknown: $1" >&2
return 2
;;
esac
done
}
parse_options --port 8080 --verbose
This is the foundational pattern for hand-rolled argument parsers. Lesson 17 covers getopts for richer cases.
3. The dangerous default: every variable is global
This is the section that changes how you write shell forever.
counter() {
COUNT=$((COUNT + 1))
echo "Counter is now $COUNT"
}
COUNT=0
counter # Counter is now 1
counter # Counter is now 2
echo "$COUNT" # 2
Looks fine, right? Now consider this:
bad_function() {
i=0
while (( i < 5 )); do
(( i++ ))
done
}
i=100
bad_function
echo "$i" # 5 — the function CLOBBERED i
Bash variables are global by default. Every assignment inside a function modifies the script-wide state. If your function uses i as a loop counter and the calling code also uses i, the function silently corrupts the caller’s i. This is the source of countless impossible-to-find bugs in larger shell scripts.
The fix: local.
good_function() {
local i=0
while (( i < 5 )); do
(( i++ ))
done
}
i=100
good_function
echo "$i" # 100 — caller's i is preserved
local declares a variable scoped to the current function. It comes into existence when the function is entered and is destroyed when the function returns. The caller’s variable of the same name is hidden during the function call (dynamic scope) and restored on return.
The rule: declare every variable inside every function with local. Always. There is no good reason not to. Functions that omit local are bugs waiting to happen.
greet() {
local name="$1"
local greeting="${2:-Hello}"
echo "${greeting}, ${name}"
}
local accepts the same attribute flags as declare:
process() {
local -i count=0 # integer
local -a items=() # array
local -A by_id=() # associative array
local -r MAX=10 # readonly within this function
local -n ref="$1" # nameref (advanced — see section 7)
}
local works only inside functions. Calling it outside a function is an error.
Dynamic scope: a subtle pitfall
Bash uses dynamic scope, not lexical. This means a function can see variables declared local in any caller further up the call stack — not just the immediate caller, but any ancestor.
outer() {
local secret="hidden"
inner
}
inner() {
echo "$secret" # prints "hidden" — inner sees outer's local
}
outer
This is not how Python, JavaScript, C, or any other major language works. Most languages have lexical scope, where inner can’t see outer’s locals. Bash’s dynamic scope means: variables leak through the call stack. Functions in deep call chains can accidentally read or write each other’s “private” state if they happen to use the same variable names.
The mitigation: use long, specific variable names (local user_email instead of local email), and prefix names by function (local greet_name="$1" instead of local name="$1"). This isn’t elegant — it’s the cost of working in a language without lexical scope. For very deep call stacks, use namerefs (section 7) to make data-passing explicit.
4. return vs exit — a critical distinction
Beginners constantly confuse these two. They are not synonyms.
return [N]— exits the current function with status N. The script keeps running.exit [N]— terminates the entire script with status N. Everything stops.
check() {
if [[ ! -f "$1" ]]; then
echo "Missing: $1" >&2
return 1 # exit the function with status 1
fi
return 0 # success
}
check /tmp/foo
echo "Continuing after check" # this still runs
vs.
check() {
if [[ ! -f "$1" ]]; then
echo "Missing: $1" >&2
exit 1 # KILL THE WHOLE SCRIPT
fi
}
check /tmp/foo
echo "This never runs if /tmp/foo doesn't exist"
Use return in functions almost always. Use exit only at the top level (in main) or in a function whose explicit purpose is “die now, don’t continue.”
If you call return from outside a function, bash treats it as exit. If you call exit from inside a function, the whole script terminates. There is no way to “exit just the function and continue the caller” other than return.
The exit code of a function
A function’s exit code is the status of the last command run inside it. If you don’t say return N explicitly, the function returns the exit code of its last command:
last_command_status() {
ls /tmp # this runs, returns 0
ls /nonexistent # this runs, returns 2 — and is the last command
}
last_command_status
echo "$?" # 2
This is occasionally what you want. Often you want to be explicit:
last_command_status() {
ls /tmp || return 1
ls /nonexistent || return 2
return 0
}
The cmd || return N pattern is the single most useful function-internal idiom: “run cmd; if it fails, return with status N.”
Returning values from functions
Shell functions cannot return arbitrary values the way functions in other languages can. The “return value” of a function is its exit status — an integer in 0–255. To “return a value,” you have three options:
Option 1: stdout (the most common)
get_user_id() {
local user="$1"
awk -F: -v u="$user" '$1 == u {print $3}' /etc/passwd
}
UID="$(get_user_id "$USER")"
echo "Your UID is $UID"
The function writes the value to stdout. The caller captures it with $(...). This is idiomatic and works for any data — strings, numbers, multi-line output. The cost: command substitution forks a subshell (more on this below in section 6).
Option 2: assign to a global variable
LAST_RESULT=""
compute() {
local x="$1"
LAST_RESULT=$((x * 2))
}
compute 5
echo "$LAST_RESULT" # 10
No fork, very fast. The cost: you’ve polluted the global namespace, and the function has hidden side effects on LAST_RESULT. Use this for hot inner loops where the fork cost matters; otherwise prefer stdout.
Option 3: namerefs (bash 4.3+)
compute() {
local -n out="$1"
out=$(( $2 * 2 ))
}
compute result 5
echo "$result" # 10
A nameref is a variable that points to another variable. The function modifies out, but out is bound to whatever name the caller passed in (result here). This is the clean version of “return a value by reference” — no fork, no global pollution. Bash 4.3+ only.
We’ll use all three throughout the course; for now, default to stdout unless you have a specific reason.
5. The main "$@" pattern: turning a script into a structured program
Once you have functions, the natural shape of a non-trivial shell script is:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# --- Constants ---
readonly DEFAULT_PORT=8080
readonly DEFAULT_LOG_LEVEL=info
# --- Functions ---
usage() {
cat <<EOF
Usage: $0 [OPTIONS] COMMAND
Options:
--port N Port to use (default: ${DEFAULT_PORT})
--log-level L Log level (default: ${DEFAULT_LOG_LEVEL})
-h, --help Show this help
Commands:
start Start the service
stop Stop the service
status Show service status
EOF
}
log() {
local level="$1"; shift
printf '[%s] [%s] %s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$*" >&2
}
require_tool() {
local tool="$1"
command -v "$tool" >/dev/null || {
log error "Required tool not found: $tool"
return 1
}
}
cmd_start() {
log info "Starting service on port ${PORT}"
# ...
}
cmd_stop() {
log info "Stopping service"
# ...
}
cmd_status() {
log info "Checking service status"
# ...
}
# --- main ---
main() {
local PORT="${DEFAULT_PORT}"
local LOG_LEVEL="${DEFAULT_LOG_LEVEL}"
local cmd=""
while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--log-level) LOG_LEVEL="$2"; shift 2 ;;
-h|--help) usage; return 0 ;;
start|stop|status) cmd="$1"; shift ;;
*) log error "Unknown argument: $1"; usage; return 2 ;;
esac
done
if [[ -z "$cmd" ]]; then
log error "Missing command"
usage
return 2
fi
require_tool curl || return 3
require_tool jq || return 3
"cmd_${cmd}"
}
main "$@"
Things to notice:
- One
mainfunction that drives the whole script. - All argument parsing happens inside
main. main "$@"at the very bottom is the entry point.- Sub-commands dispatched by name:
cmd_${cmd}— ifcmdisstart, this callscmd_start. Cleaner than a longcaseblock at the top level. - Every function has
localvariables. logandusageare short utilities.require_toolis a reusable helper.- Errors return distinct exit codes: 2 = usage error, 3 = missing tool.
This is the shape of every production shell script you should write past 100 lines. The main "$@" pattern is borrowed from C and Python and works equally well here.
Why main at the bottom?
Two reasons:
1. Functions must be defined before they’re called. Bash parses top-to-bottom. If main calls cmd_start, then cmd_start must already be defined when main runs. If main is at the top of the file calling functions defined below, it works only if main itself isn’t called until after the file is fully read. The convention is: put main "$@" at the very bottom of the file, so by the time it runs, everything is defined.
2. It makes the script “sourceable” and “testable.” If you put main "$@" at the bottom and someone sources your script, all the functions get defined but main runs too — which they probably didn’t want. The trick is to guard main:
# Only run main if the script is being executed, not sourced
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
BASH_SOURCE[0] is the path of the current source file. $0 is the path of the running script (or shell). When you run the script directly, they’re equal. When you source it, $0 is your interactive shell (e.g. -bash) but BASH_SOURCE[0] is the file. The guard runs main only in the run-directly case.
This is the canonical pattern for scripts that double as libraries — useful for testing (lesson 19) and for shell-based modular code.
6. The fork cost of $() — and when to care
Every time you write VAR=$(some_command), bash forks a subshell to run some_command. The output is captured, the subshell exits, and VAR is set in the parent.
Forks are cheap on modern Linux — typically 100µs to 1ms. For a script that runs ten of them, you’ll never notice. For a tight loop that runs ten thousand of them, you’ll notice a lot:
# Slow — forks twice per iteration
for i in {1..10000}; do
TIMESTAMP=$(date +%s)
ID=$(uuidgen)
echo "$TIMESTAMP $ID"
done
Each call to date forks a process and execs the binary. Ten thousand iterations = 10,000 forks for date plus 10,000 for uuidgen. On a typical Linux server, that’s 10–60 seconds of pure fork overhead.
The fix: use bash built-ins where possible.
# Fast — no fork per iteration
for i in {1..10000}; do
printf -v TIMESTAMP '%(%s)T' -1 # bash 4.2+; format current time without forking
printf -v ID '%s%s' "$$" "$RANDOM" # cheap pseudo-id
echo "$TIMESTAMP $ID"
done
printf -v VAR FMT writes the formatted output directly into VAR without a fork. The %(%s)T format with -1 gives the current Unix timestamp using bash’s built-in time formatter.
For the same reason, prefer parameter expansion over sed/awk/cut for simple string manipulation:
# Slow
BASE=$(echo "$FILE" | sed 's|.*/||')
# Fast (no fork, see L2)
BASE="${FILE##*/}"
In a hot loop, this difference can be 100x. Lesson 32 (performance) covers this in depth, but the principle is foundational: forking is what makes shell slow; eliminating forks is what makes shell fast.
7. Namerefs — passing data by reference (bash 4.3+)
Sometimes you need a function that updates a variable in the caller’s scope by name, without using a global. The clean way is local -n:
double_in_place() {
local -n var="$1" # var is now an alias for whatever variable the caller named
var=$((var * 2))
}
x=5
double_in_place x
echo "$x" # 10
The local -n var="$1" line says: “create a local variable var that is a reference to whatever variable name was passed as $1.” Inside the function, var and the caller’s x are the same storage.
This works for arrays too:
add_to() {
local -n arr="$1"
arr+=("$2")
}
FRUITS=(apple banana)
add_to FRUITS cherry
echo "${FRUITS[@]}" # apple banana cherry
The classic use case: a function that needs to “return” multiple values. Instead of stdout-and-parse, you pass in references to fill:
parse_url() {
local url="$1"
local -n out_proto="$2"
local -n out_host="$3"
local -n out_path="$4"
[[ "$url" =~ ^([^:]+)://([^/]+)(.*)$ ]] || return 1
out_proto="${BASH_REMATCH[1]}"
out_host="${BASH_REMATCH[2]}"
out_path="${BASH_REMATCH[3]}"
}
parse_url "https://example.com/api" PROTO HOST PATH
echo "Protocol: $PROTO, Host: $HOST, Path: $PATH"
Pitfalls:
- Bash 4.3+. Won’t work on macOS’s default bash (3.2). Use
brew install bashor rewrite without namerefs. - The nameref’s name and the caller’s variable name must not clash. If you do
local -n x="$1"and the caller passedx, you get a “circular reference” error. Conventional fix: prefix nameref names with something unlikely to collide (local -n __out="$1"). - Namerefs make scripts harder to reason about because the data flow is hidden. Use sparingly.
8. Function visibility: export -f and subshells
When you fork a subshell (running an external command, a $(...) substitution, or a pipe), the function definitions are not inherited by default. The new process is a fresh shell that doesn’t know your functions exist.
greet() { echo "Hello, $1"; }
bash -c 'greet World' # bash: greet: command not found
To export a function so subshells inherit it:
greet() { echo "Hello, $1"; }
export -f greet
bash -c 'greet World' # Hello, World
export -f FUNC is the function-equivalent of export VAR. Useful when you want to call your shell functions inside xargs, find -exec, GNU parallel, etc:
process_file() {
local f="$1"
echo "Processing: $f"
gzip -- "$f"
}
export -f process_file
find /var/log -type f -name '*.log' -print0 | xargs -0 -P 4 -I {} bash -c 'process_file "$@"' _ {}
The bash -c '...' _ {} is a small trick: _ becomes $0 (placeholder), {} becomes $1, and our function is called with the filename. Without export -f, the inner bash wouldn’t know process_file exists.
9. Recursion in shell
Shell functions can recurse. There’s no practical depth limit beyond the bash stack size, and the cost is just function-call overhead (very cheap — no fork).
factorial() {
local n="$1"
if (( n <= 1 )); then
echo 1
return
fi
echo "$(( n * $(factorial $((n - 1))) ))"
}
factorial 5 # 120
Each recursive call is a $(...) substitution, which forks a subshell. So the above isn’t actually fork-free. For real performance you’d accumulate in a global:
RESULT=1
factorial_acc() {
local n="$1"
if (( n <= 1 )); then
return
fi
RESULT=$((RESULT * n))
factorial_acc $((n - 1))
}
RESULT=1
factorial_acc 5
echo "$RESULT" # 120
Recursion in shell is fine for tree-walking, recursive descent, parsing, etc. — but if you find yourself recursing deep, ask whether shell is the right tool. Lesson 32 covers when to leave shell.
10. Twelve function idioms to memorise
# 1. Always declare arguments local at the top
greet() {
local name="$1"
local greeting="${2:-Hello}"
echo "${greeting}, ${name}"
}
# 2. Forward all arguments
wrap() {
./real-tool "$@"
}
# 3. Cmd-or-return: fail-fast inside functions
check() {
command -v jq >/dev/null || return 1
jq --version
}
# 4. Stdout return value
hostname_short() {
hostname -s
}
H="$(hostname_short)"
# 5. Nameref return (bash 4.3+)
get_pid() {
local -n out="$1"
out=$$
}
get_pid MY_PID
# 6. Distinct exit codes per failure mode
validate() {
[[ -f "$1" ]] || return 1
[[ -r "$1" ]] || return 2
[[ -s "$1" ]] || return 3
return 0
}
# 7. main "$@" pattern
main() {
# ...
}
main "$@"
# 8. Sourceable script guard
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
# 9. log helper
log() {
printf '[%s] [%s] %s\n' "$(date -Iseconds)" "$1" "${*:2}" >&2
}
# 10. Check required env once
require_env() {
local var="$1"
[[ -n "${!var:-}" ]] || { echo "Required: $var" >&2; exit 2; }
}
# 11. Sub-command dispatch
main() {
local cmd="$1"; shift
case "$cmd" in
start|stop|status|restart) "cmd_${cmd}" "$@" ;;
*) echo "Unknown: $cmd" >&2; return 2 ;;
esac
}
# 12. Exported helper for find/xargs
process() { echo "Processing $1"; }
export -f process
find . -type f -print0 | xargs -0 -I {} bash -c 'process "$@"' _ {}
These twelve cover 95% of function-use in real-world scripts.
11. A complete example: a small but realistic deployment script
#!/usr/bin/env bash
# deploy.sh — push a built artifact to a remote host
set -euo pipefail
IFS=$'\n\t'
# ---- Constants ----
readonly REQUIRED_TOOLS=(ssh scp jq curl)
# ---- Helpers ----
log() {
local level="$1"; shift
printf '[%s] [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$*" >&2
}
die() {
log error "$*"
exit 1
}
require_tools() {
local missing=()
for tool in "${REQUIRED_TOOLS[@]}"; do
command -v "$tool" >/dev/null || missing+=("$tool")
done
if (( ${#missing[@]} > 0 )); then
die "Missing tools: ${missing[*]}"
fi
}
require_env() {
local var="$1"
[[ -n "${!var:-}" ]] || die "Required environment variable: $var"
}
# ---- Sub-commands ----
cmd_validate() {
local artifact="$1"
log info "Validating ${artifact}"
[[ -f "$artifact" ]] || { log error "Artifact missing: $artifact"; return 2; }
[[ -s "$artifact" ]] || { log error "Artifact empty: $artifact"; return 3; }
log info "OK"
}
cmd_upload() {
local artifact="$1"
local host="$2"
local target="$3"
log info "Uploading ${artifact} -> ${host}:${target}"
scp -q -- "$artifact" "${host}:${target}" || { log error "Upload failed"; return 4; }
log info "Upload OK"
}
cmd_health_check() {
local url="$1"
local timeout="${2:-30}"
log info "Health check: ${url} (timeout ${timeout}s)"
for ((i=0; i<timeout; i++)); do
if curl -fsS "$url" >/dev/null 2>&1; then
log info "Healthy after ${i}s"
return 0
fi
sleep 1
done
log error "Health check timed out after ${timeout}s"
return 5
}
# ---- main ----
main() {
local artifact="${1:-}"
local host="${HOST:-}"
local target="${TARGET_PATH:-/opt/app}"
[[ -n "$artifact" ]] || die "Usage: $0 ARTIFACT (HOST=... TARGET_PATH=...)"
require_env HOST
require_tools
cmd_validate "$artifact"
cmd_upload "$artifact" "$host" "$target"
cmd_health_check "https://${host}/healthz" 60
log info "Deployment complete"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
Read this carefully. Every pattern from this lesson is in here:
localfor every function variable.returnfor function-level failures,exitonly at the script-top level.diehelper that combines log + exit.require_toolsandrequire_envas named, reusable helpers.cmd_*sub-command convention.main "$@"at the bottom, behind a sourceable guard.- Distinct exit codes per failure mode (2 = missing, 3 = empty, 4 = upload, 5 = health).
- Strict-mode preamble.
If you can write this cleanly, you have everything you need to grow your shell scripts to 500+ lines without them collapsing under their own weight.
12. What you must internalise before lesson 6
- Why is every variable global by default in bash, and why is that dangerous? (Bash defaults to global; functions can clobber caller state silently.)
- What’s the fix? (
localdeclaration on every variable inside every function.) - What’s the difference between
return Nandexit N? (returnexits a function;exitterminates the entire script.) - What does
cmd || return Ndo? (Run cmd; if it fails, return from the function with status N.) - What are the three ways to “return a value” from a function? (Stdout +
$(...)capture; global variable; namereflocal -n.) - What does
main "$@"do at the bottom of a script? (Callsmainwith the script’s positional parameters, after all functions have been parsed.) - What’s the source-guard idiom? (
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi— runsmainonly when the script is executed directly, not when sourced.) - What does
export -f FUNCdo? (Marks the function for inheritance by subshells, useful forxargsandfind -exec.) - What is dynamic scope and why does it bite? (Inner functions can see outer functions’ locals — different from C/Python lexical scope; mitigated by long, specific variable names.)
- When does the fork cost of
$(...)matter? (Tight inner loops with thousands of iterations; the fix is bash built-ins likeprintf -vand parameter expansion.)
If any felt fuzzy, re-read. Lesson 6 (arrays) is where we put structured data into scripts and the local and quoting patterns from this lesson become non-negotiable.
What’s next
Lesson 6 covers indexed arrays (arr=(a b c), ${arr[@]}, ${#arr[@]}), associative arrays (declare -A by_id), array slicing, the mapfile/readarray builtins for line-oriented data, and the iteration patterns that use arrays as the canonical data structure. Bring everything from lessons 1–5 — every array operation is a quoting and word-splitting decision in disguise.