Functions & arguments

Reusable blocks with their own parameters.

Beginner16 min · lesson 7 of 14

A Bash function is a recipe card you write once and reuse. You give the card a name and jot down the steps. Later, whenever you say that name, Bash runs those steps again. In DevSecOps work (building security into how software gets written, tested, and shipped, instead of bolting it on at the end), functions are how a sprawling 300-line script turns into a short list of small, named steps you can read, trust, and test one at a time.

A function is a named recipe

You write a function as a name, an empty pair of parentheses, and a body wrapped in curly braces. The parentheses are punctuation that says 'this is a function'; you never put anything inside them. To run it later, you write its name on a line by itself, the same way you would run any other command.

greet.sh
#!/usr/bin/env bash
greet() {
echo "Hello, $1. You are visitor number $2."
}
greet "Ada" 7
greet "Grace" 8
~/secopslog — bash
$ bash greet.sh
Hello, Ada. You are visitor number 7. Hello, Grace. You are visitor number 8.

You passed Ada and 7 after the name. Inside the function they showed up as $1 and $2. A Bash function never declares its inputs anywhere. Whatever you type after the name becomes the numbered arguments the body can read.

The function has its own $1 and $2

Here is the part that trips people up. When a script starts, $1 holds the first thing the user typed after the script's name. Step inside a function and those numbers quietly change meaning. Now $1, $2, and the rest (their real name is positional parameters, because they identify each argument by its position in line) hold what you passed to the function, not what was passed to the script. Each function is a small workshop with its own numbered lockers: same numbers on the doors, different contents inside.

rooms.sh
#!/usr/bin/env bash
show() {
echo "inside show, \$1 is: $1"
}
echo "script \$1 is: $1"
show "banana"
echo "script \$1 is still: $1"
~/secopslog — bash
$ bash rooms.sh apple
script $1 is: apple inside show, $1 is: banana script $1 is still: apple

The script ran with apple, so the script's $1 is apple. Inside show, $1 is banana, the value handed to that one call. The moment show returns, the script's $1 is apple again, untouched. The two sets of arguments never bleed into each other.

Two ways to hand something back

A function can hand you back two very different kinds of thing, and mixing them up is one of the most common early mistakes. The first is a status code: a single whole number from 0 to 255 that answers one question, 'did this work?' Zero means success. Anything else means some kind of failure. You set it with return, and you read it right afterward from a special variable spelled $?. The second is a result: actual data, like a number or a filename. You produce that by printing it with echo, and you catch it with $(...), which runs the function and hands you back the text it printed.

handback.sh
#!/usr/bin/env bash
# answers a yes/no question with a STATUS CODE
is_even() {
(( $1 % 2 == 0 ))
}
# produces DATA by printing it
double() {
echo $(( $1 * 2 ))
}
if is_even 10; then echo "10 is even"; fi
if is_even 7; then echo "7 is even"; else echo "7 is odd"; fi
result=$(double 21)
echo "double of 21 is $result"
~/secopslog — bash
$ bash handback.sh
10 is even 7 is odd double of 21 is 42

is_even prints nothing at all. Its last line is an arithmetic test, and whether that test passes or fails becomes the function's status, which the if statement reads straight off. double is the mirror image: it prints a number and says nothing about success, so $(double 21) captures that printed text into result. Yes/no questions ride on the status code. Real data rides on printed output.

Two ways a function hands something back
Function finishes
what does the caller get?
a decision
return 0..255
read it from $?; drives if / && / ||
a value
echo the data
catch it with result=$(func)
Pick one on purpose. A status code answers a decision; printed output carries a value.
wrap.sh
#!/usr/bin/env bash
count_lines() {
return 300 # bigger than 255
}
count_lines
echo "status came back as: $?"
~/secopslog — bash
$ bash wrap.sh
status came back as: 44
return carries a status, not a value
return only understands whole numbers from 0 to 255. Hand it 300 and Bash quietly wraps it around, so 300 comes back as 44 (that is 300 minus 256). If a function needs to return a count, a file size, or any real value, print it with echo and capture it with $(...). Keep return for plain success or failure.

local keeps a variable inside its function

By default, a variable you set inside a function is not private to it. It reaches out and overwrites any variable of the same name elsewhere in the script. That is the source of some genuinely nasty bugs, where a small helper quietly changes a value the main script was counting on, and the breakage surfaces far from the line that caused it. A plain variable is like writing on the shared office whiteboard: your note lands on top of whatever was there, in front of everyone. The word local turns it into scratch paper the function balls up and throws away when it ends.

scope.sh
#!/usr/bin/env bash
leaky() {
name="report.txt" # no 'local' -> escapes the function
echo "leaky picked: $name"
}
safe() {
local name="report.txt" # 'local' -> stays inside
echo "safe picked: $name"
}
name="production.db"
echo "start: name=$name"
leaky
echo "after leaky: name=$name"
name="production.db" # reset
safe
echo "after safe: name=$name"
~/secopslog — bash
$ bash scope.sh
start: name=production.db leaky picked: report.txt after leaky: name=report.txt safe picked: report.txt after safe: name=production.db

Watch the name variable. Without local, leaky set name to report.txt and that assignment escaped, stomping on the script's name, which had been pointing at production.db. safe made the exact same assignment, but local kept it inside, so the script's value lived through the call. This is a security habit as much as a tidiness one. An unlocalized variable can drag a value from an untrusted source (a filename from user input, an access token, a path) into code that assumed the old, safe value was still there. Put local in front of every variable a function creates.

A log function worth copying into every script

Ops scripts speak two languages at once. One is data: the real answer the script computes, which usually gets captured into a variable or piped into the next tool. The other is chatter: progress notes, warnings, timestamps for the human watching the run. Send both to the same place, which is standard output (the default stream, where a command's normal results go), and your chatter poisons the data. The fix is to route the chatter to standard error (stderr, a second stream that exists specifically for diagnostics and messages) using >&2.

backup.sh
#!/usr/bin/env bash
log() {
local level="$1"; shift
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $level: $*" >&2
}
log INFO "starting nightly backup"
config="db_host=10.0.0.5" # stands in for real data sent to stdout
echo "$config"
log INFO "backup finished"

Inside log, the first argument is the level (INFO, WARN, and so on). shift then drops that first argument after we have saved it, sliding every remaining argument down one slot, so what is left is the message itself. $* joins those leftover words back into one string. One small function timestamps and labels any message you hand it.

~/secopslog — bash
$ bash backup.sh
[2026-07-17 09:41:07] INFO: starting nightly backup db_host=10.0.0.5 [2026-07-17 09:41:07] INFO: backup finished
$ out=$(bash backup.sh 2>/dev/null) echo "captured: [$out]"
captured: [db_host=10.0.0.5]

The first run shows everything, because your terminal displays both streams side by side. In the second, out=$(bash backup.sh 2>/dev/null) captures standard output and sends standard error to /dev/null (the system's trash can, where anything you write vanishes). The log lines disappear and out holds only db_host=10.0.0.5. Your logs stayed visible while a human watched, and got out of the way when a machine read the result.

A function that answers yes or no

Status codes earn their keep when a function makes a decision for you. Here is a guard a real deployment script might run before it touches a file whose name came from outside: it checks that the name is a plain filename and not an attempt to climb out of the folder. Climbing out with ../ is called path traversal (using ../ to walk up into directories the script was never meant to reach, like /etc/passwd, the file that lists every account on a Linux machine). Catching it early is the kind of cheap check that stops a handy little script from turning into a way in.

guard.sh
#!/usr/bin/env bash
log() {
local level="$1"; shift
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $level: $*" >&2
}
# 0 (success) for a safe plain name, 1 (failure) if it tries to escape
is_safe_name() {
local name="$1"
[[ "$name" == *"/"* ]] && return 1
[[ "$name" == *".."* ]] && return 1
return 0
}
for candidate in "report.txt" "../../etc/passwd"; do
if is_safe_name "$candidate"; then
log OK "using $candidate"
else
log DENY "refusing unsafe path: $candidate"
fi
done
~/secopslog — bash
$ bash guard.sh
[2026-07-17 09:42:15] OK: using report.txt [2026-07-17 09:42:15] DENY: refusing unsafe path: ../../etc/passwd

is_safe_name prints nothing. It returns 0 for a safe name and 1 for a dangerous one, and the if statement branches on that status. Every message went out through log to standard error, so even inside a bigger pipeline the DENY line still reaches your screen without mixing into whatever data the script is producing on standard output. One honest caveat: this guard is a denylist, and blocking / and .. is the right first cut, but a stricter script would flip to an allowlist and accept only the characters it expects, such as letters, digits, dots, dashes, and underscores.

Quick check
01A function ends with the single line `echo $(( $1 * 3 ))` and nothing else. How should the caller get the tripled number back?
Correct — echo sends the number to standard output, and $(...) runs the function and captures whatever it printed, so result becomes 15.
Incorrect — $? reads the status code (0 to 255), which only says whether the echo succeeded. It would give 0, not 15.
Incorrect — Without $(...), this assigns the word 'triple' to result and then tries to run 5 as a command. You need $(...) to run the function and capture its output.
Incorrect — return carries only a status code 0 to 255. 15 would fit by luck, but a larger result like 300 would wrap to 44. Use echo for real values.
02Inside a function you write `name="report.txt"` without the word `local`, while the surrounding script already holds `name="production.db"`. After the function returns, what is the script's `name`?
Incorrect — Bash variables are global by default; only `local` confines an assignment to the function.
Correct — an unlocalized assignment reaches out and stomps the same-named variable in the caller, which is exactly the bug `local` prevents.
Incorrect — a function's positional parameters revert, but a plain non-local variable assignment does not roll back.
Incorrect — reusing a name is legal; Bash simply overwrites the existing value.
03A helper `get_host` computes a value and prints it with `echo`, but it also prints progress notes with a plain `echo "checking..."` (no redirection). A caller runs `h=$(get_host)`. What ends up in `h`?
Incorrect — command substitution captures everything written to standard output, progress notes included.
Incorrect — the capture still succeeds; the problem is that it grabs too much, not that it fails.
Incorrect — `$(...)` collects all of standard output, not just the final line.
Correct — chatter and data share standard output unless you redirect the chatter, so routing notes with `>&2` keeps the captured value clean.

Three habits make functions behave. Open each one with local for its variables. Decide up front whether it answers a question with return or produces data with echo, and never blur the two. Send all of its talking through a log helper aimed at standard error. Wire those in from the first line you write, and your scripts stop ambushing you at the worst possible moment.

Related