A 300-line bash deploy script we inherited had the same six-line error-checking block copy-pasted eleven times — once after each critical command. Ten copies were identical. The eleventh had a single typo: 2>/dev/null where the others wrote 2>&1, which meant that one command's failures were discarded instead of logged. For four months a specific failure path produced no error in the logs and no alert, while the deploy quietly continued past a step that had not actually succeeded. The fix was not "be more careful copy-pasting." The fix was a function. A function is not just the unit of reuse in bash — it is the unit of reliability. Logic that exists in exactly one place can only be wrong in one place, and when you fix it, it is fixed everywhere.
Declaring and calling functions
Bash accepts two declaration syntaxes:
Both define the same callable. Call it like a command, with arguments as positional words and no parentheses: log_info "starting backup". Choose the name() form — it is portable and unambiguous — and use it consistently. A codebase that mixes both styles signals that nobody decided on a convention, and inconsistency is where bugs hide.
Return values: exit codes, not strings
This is the concept that trips up everyone arriving from another language. return in bash does not return data. It sets the function's exit status, an integer from 0 to 255, where 0 means success:
To hand back an actual value, you have two correct patterns. The first is echo plus command substitution — the function prints its result, and the caller captures stdout:
The second is a nameref output variable (local -n, bash 4.3 and later), where the caller passes the name of a variable to fill:
Echo-and-capture is simpler and the right default. The nameref form avoids spawning a subshell and lets one function set several outputs, which matters in a hot loop. Here is a single script using both, so the contrast is concrete:
Local variables prevent silent corruption
Bash variables are global by default. A variable assigned inside a function without local modifies the script-wide variable of that name. This produces one of the nastiest bugs in shell scripting because it fails silently:
The outer loop's counter is destroyed by the inner function because both use i and the inner one is global. Add local i inside process_batch and the outer loop runs correctly. Make local a reflex: every variable a function assigns gets local, no exceptions. The cost of a missing local is a wrong number five functions away with no error to trace it back to.
Passing and validating arguments
Arguments arrive as $1, $2, and so on; $# is the count; $@ is all of them. Validate required arguments at the top of the function so a missing one fails immediately with a clear message rather than corrupting later logic:
The ${1:?message} form aborts with your message if $1 is unset or empty, turning a vague downstream failure into a precise one at the call site.
A reusable function library
These three functions appear in nearly every production script — standardized logging and a dependency guard. Define them once, source them everywhere:
log_error writes to stderr (>&2) so the message is not lost when a caller redirects stdout to a file, and require_command fails the script up front rather than halfway through when a missing jq or curl finally gets invoked.
Testing a function in isolation
You do not need to run the whole script to test one function. Guard the main execution so that sourcing the file defines the functions without running anything:
Now in an interactive shell you can source ./script.sh and call require_command jq or build_filename test directly, inspecting the result without triggering the full run. That is unit testing in bash: source the file, call the function, check the output. Functions you can test in isolation are functions you can trust in production.