Functions: Reusable Code
- Explain what a function is — a named mini-command you define — and why real scripts are built from them
- Define and call functions with parameters
- Keep variables local, and explain the action-at-a-distance bug that local prevents
- Return status with exit codes and return data by output — and know why they're different channels
- Handle a script's arguments and exit codes (its interface to the outside world)
Why functions: you're building your own commands
Build the mental model — a function is a command you taught the shell
You already think in commands:
lslists,grepsearches,dumeasures. Each is a black box with a job, inputs, and a success/failure verdict. A function is exactly that — except you define what it does.deploy_app,check_disk,log_error: once defined, you call them like any other command, arguments and all.That's a more useful mental model than "reusable code block," because it tells you how functions behave: they take arguments the same way commands do, they report success or failure the same way commands do (exit codes — the currency you learned with conditionals), and they slot into
if,&&, and pipelines the same way commands do. Nothing new to learn about using them; the news is that you can make them.Why bother, in a 30-line script? Three compounding reasons:
1. One place to fix. If timestamp formatting lives in a
logfunction, changing the format is a one-line edit — not a hunt through every echo in the file.2. Readable at a glance. A main body that reads
check_prereqs,backup_db,rotate_logsis a table of contents. The same logic inlined is a wall of plumbing.3. Testable in isolation. You can paste one function into a terminal and poke at it — try that with line 47 of a monolithic script.
Check yourself
A function reports success or failure to its caller. Based on how everything else in the shell works, what mechanism would you predict it uses?
Define and call a function
Package logic under a name and pass it parameters
The syntax is minimal: a name, empty parentheses (always empty — they're just a marker), and a body in braces. Calling it looks exactly like running a command — no parentheses at the call site, arguments separated by spaces.
Inside the body, arguments arrive as positional parameters:
$1is the first,$2the second, and so on — the same convention a script uses for its arguments. One rule of order: the definition must be read by the shell before the call, which is why functions live at the top of a script.greet_user() { echo "Good $2, $1!" } greet_user Alice morningGood morning, Alice!
Alice lands in $1, morning in $2, and the body weaves them into the message. Notice what the call looks like: greet_user Alice morning — indistinguishable from any two-argument command. That's the point. log() { echo "[$(date +%H:%M:%S)] $*" } log "backup starting" log "copied" 42 "files"[14:02:11] backup starting [14:02:11] copied 42 files
- $*
- all positional arguments joined into one string — right for building a message
- +%H:%M:%S
- (date) format: hours:minutes:seconds
The single most-defined function in operations: a timestamped logger. $* means "all the arguments, joined" — so log accepts any number of them. Define this once at the top of a script and every status line gets a timestamp for free; change the format later and you change it in exactly one place. Check yourself
Inside the function body, how do you refer to the second argument it was called with?
Keep variables local
Stop a function's variables leaking into the rest of the script
Here's the sharpest edge in Bash functions, and it's invisible until it cuts you: a variable assigned inside a function is global by default. Set
result=5in a helper and you've setresultfor the entire script — including whatever value the caller had in a variable of the same name. The function reaches out and silently overwrites state it was never meant to touch: action at a distance.The fix is one word. Declare the variable `local` and it exists only inside that function call, vanishing when the function returns — like notes on scratch paper versus writing on the shared whiteboard.
bad() { result="leaked"; } good() { local result="contained"; } result="original" bad echo "after bad: $result" result="original" good echo "after good: $result"after bad: leaked after good: original
Same shape, one word different. bad's assignment escaped and clobbered the caller's variable; good's local kept it contained, and the caller's value survived. Read that output twice — it's the entire scoping story in four lines. Check yourself
Which keyword confines a variable to the function it's declared in?
Return status vs return data — two different channels
Signal success/failure via exit code, hand back values via output
Coming from Python or JavaScript you'd expect
return some_string. Bash will surprise you: `return` only carries a number from 0 to 255 — it sets the function's exit code, nothing else. So functions have two separate channels, and choosing the right one is what makes them idiomatic:- Status — the yes/no verdict.
return 0= success,return 1(+) = failure; if you don'treturnexplicitly, the function inherits the exit code of its last command. This channel plugs straight intoifand&&. - Data — an actual value. The function echoes it, and the caller captures with
$( )— the same command substitution you'd use fordateorhostname, because a function is a command.
is_root() { [ "$EUID" -eq 0 ]; } if is_root; then echo "running as root"; else echo "not root"; finot root
- EUID
- effective user ID of the shell — 0 means root
A predicate function — the status channel in its purest form. The body is a single test; the function's exit code IS that test's exit code (no explicit return needed). That's why is_root slots directly into if with no brackets, no comparison — it's a command answering yes or no. get_backup_name() { echo "backup-$(hostname)-$(date +%F).tar.gz" } filename=$(get_backup_name) echo "writing to: $filename"writing to: backup-web01-2026-07-10.tar.gz
The data channel: the function builds a value and echoes it; the caller captures it with $( ) into a variable. This pair — echo inside, $( ) outside — is Bash's version of 'return a string'. Check yourself
Your function computed the string "/srv/backups/monday". How does it hand that back to the caller — return, or something else?
- Status — the yes/no verdict.
A script's interface: arguments and exit codes
Validate input, report usage to stderr, and exit non-zero like a proper tool
Zoom out one level: everything a function does with
$1and exit codes, a script does with the outside world. Your script's arguments and its final exit code are its interface — they're what cron, CI pipelines, and other scripts see. A well-behaved tool holds up three ends of the contract: it validates its arguments before acting, it prints usage errors to stderr (so they don't pollute captured output), and it exits non-zero on failure (so callers can tell).cat > needs-arg.sh <<'EOF' #!/bin/bash if [ "$#" -lt 1 ]; then echo "usage: $0 <file>" >&2 exit 2 fi echo "processing $1" EOF cat needs-arg.sh#!/bin/bash if [ "$#" -lt 1 ]; then echo "usage: $0 <file>" >&2 exit 2 fi echo "processing $1"
- $#
- number of arguments the script received — check it before touching $1
- >&2
- redirect this output to stderr — the channel for errors and usage, not data
- $0
- the script's own name — so usage messages stay correct if the file is renamed
The guard every script should open with. $# is the argument count — if nothing was passed, print a usage line naming the script ($0), send it to stderr with >&2, and exit 2. Now ./needs-arg.sh || echo failedworks, cron notices the failure, and a pipeline that captures stdout doesn't end up with a usage message inside its data.cat > getopts.sh <<'EOF' #!/bin/bash while getopts "vf:" opt; do case "$opt" in v) verbose=1 ;; f) file="$OPTARG" ;; *) echo "usage: $0 [-v] [-f file]" >&2; exit 2 ;; esac done EOF cat getopts.sh#!/bin/bash while getopts "vf:" opt; do case "$opt" in v) verbose=1 ;; f) file="$OPTARG" ;; *) echo "usage: $0 [-v] [-f file]" >&2; exit 2 ;; esac done- vf:
- accept -v (no value) and -f VALUE (colon = takes an argument)
- OPTARG
- holds the value supplied to the current flag
Real flag parsing — the step up from positional arguments. The option string "vf:" declares two flags: v takes no value, f does (that's the colon), and the value lands in $OPTARG. Users can now write -v -f data.txt, -vf data.txt, or -f data.txt -v and they all parse. The * arm catches unknown flags with a proper usage-and-exit. getoptsis for options (-v,-f file); when a script just takes a list of positional arguments to work through one by one, the idiom is `shift`.shiftthrows away$1and slides every remaining argument down a slot — the old$2becomes$1,$3becomes$2, and$#drops by one. Loopwhile [ "$#" -gt 0 ], handle$1, thenshift, and you consume the whole argument list cleanly no matter how long it is. (shift 2drops two at once.) Reach forshiftto walk a variable-length list of files or names; reach forgetoptswhen you're parsing flags.cat > shift-demo.sh <<'EOF' #!/bin/bash while [ "$#" -gt 0 ]; do echo "handling: $1" shift done EOF bash shift-demo.sh alpha beta gammahandling: alpha handling: beta handling: gamma
The loop handles $1, thenshiftpromotes the next argument into$1, so the same two lines process every argument in turn until$#reaches 0. Run it with two args or ten — the loop doesn't care. This is the positional-list counterpart to thegetoptsflag-parsing loop above.Check yourself
Why does the usage message go to stderr (>&2) instead of plain stdout?
Now do it — build a tiny function library in your live terminal
Write, break, and fix the three core patterns yourself
Functions become natural the day you stop copying them and start composing them. Build a three-function toolkit — a logger, a predicate, a data function — then deliberately reproduce the two classic bugs (the global leak and the crossed channel) so you'll recognize them in the wild.
log() { echo "[$(date +%H:%M:%S)] $*"; } is_num() { [[ "$1" =~ ^[0-9]+$ ]]; } log "starting" if is_num 42; then log "42 is a number"; fi is_num "forty-two" || log "forty-two is not"A logger (data assembly with $*) and a predicate (a regex test whose exit code IS the answer). Notice is_num working both ways — inside an if, and with || — because it speaks the exit-code contract every shell construct understands. greet() { local who="${1:-world}"; echo "hello, $who"; } greet greet AliceA local variable plus a default value: ${1:-world} means "$1, or 'world' if unset/empty". Call it both ways and watch the default kick in. Defaults make functions forgiving — callers supply only what's non-obvious. counter() { n=99; } n=1 counter echo "n is now: $n"Reproduce the leak: counter forgot local, so your n=1 is gone — the echo prints 99. Now add local (counter() { local n=99; }), reset n=1, and run again: n survives. You've just fixed the exact bug that eats hours in real scripts. get_name() { echo "probing..."; echo "web01"; } name=$(get_name) echo "got: [$name]"Reproduce the crossed channel: the debug echo gets captured along with the answer — the brackets show 'probing...' welded into the value. Fix it by sending the debug line to stderr (echo "probing..." >&2) and capture again: clean. Data on stdout, diagnostics on stderr — forever.
- A function is a command you define: name() { ... } before first use, called like any command, arguments arriving as $1, $2, … — real scripts are a stack of functions plus a short main body.
- Variables assigned in functions are GLOBAL unless you say local — one missing keyword lets a helper silently clobber its caller's state (the loop-counter-named-i bug).
- Two return channels: status (exit code via return / last command — plugs into if, &&) and data (echo + capture with $( )). Never mix debug output into the data channel; send it to stderr.
- A script's interface is its arguments and exit code: validate $# first, print usage to stderr (>&2), exit non-zero on failure, parse flags with getopts, and pass arguments along as "$@".
- Defaults like ${1:-fallback} make functions forgiving; predicates (a function whose body is a test) make conditionals read like English: if is_root; then …
| Concept | Syntax |
|---|---|
| Define / call | f() { ...; } · f arg1 arg2 |
| Local variable | local x=... |
| Default value | ${1:-fallback} |
| Return status | return 0 / non-zero → use in if f; then |
| Return data | echo val inside → x=$(f) outside |
| Debug from a data function | echo "msg" >&2 |
| Validate args | [ "$#" -lt 1 ] && { echo "usage: $0 ..." >&2; exit 2; } |
| Parse options | while getopts "vf:" o; do ... $OPTARG |
