Functions & arguments
Reusable blocks with their own parameters.
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.
#!/usr/bin/env bashgreet() {echo "Hello, $1. You are visitor number $2."}greet "Ada" 7greet "Grace" 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.
#!/usr/bin/env bashshow() {echo "inside show, \$1 is: $1"}echo "script \$1 is: $1"show "banana"echo "script \$1 is still: $1"
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.
#!/usr/bin/env bash# answers a yes/no question with a STATUS CODEis_even() {(( $1 % 2 == 0 ))}# produces DATA by printing itdouble() {echo $(( $1 * 2 ))}if is_even 10; then echo "10 is even"; fiif is_even 7; then echo "7 is even"; else echo "7 is odd"; firesult=$(double 21)echo "double of 21 is $result"
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.
#!/usr/bin/env bashcount_lines() {return 300 # bigger than 255}count_linesecho "status came back as: $?"
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.
#!/usr/bin/env bashleaky() {name="report.txt" # no 'local' -> escapes the functionecho "leaky picked: $name"}safe() {local name="report.txt" # 'local' -> stays insideecho "safe picked: $name"}name="production.db"echo "start: name=$name"leakyecho "after leaky: name=$name"name="production.db" # resetsafeecho "after safe: name=$name"
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.
#!/usr/bin/env bashlog() {local level="$1"; shiftecho "[$(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 stdoutecho "$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.
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.
#!/usr/bin/env bashlog() {local level="$1"; shiftecho "[$(date +'%Y-%m-%d %H:%M:%S')] $level: $*" >&2}# 0 (success) for a safe plain name, 1 (failure) if it tries to escapeis_safe_name() {local name="$1"[[ "$name" == *"/"* ]] && return 1[[ "$name" == *".."* ]] && return 1return 0}for candidate in "report.txt" "../../etc/passwd"; doif is_safe_name "$candidate"; thenlog OK "using $candidate"elselog DENY "refusing unsafe path: $candidate"fidone
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.
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.