Functions in Shell
Every script you’ve written so far in this course has been one long, flat sequence of commands. Functions are how you break that up into named, reusable pieces — and Bash functions have a couple of behaviors that genuinely surprise people coming from other languages, most notably that return cannot send back arbitrary data the way a function return value does elsewhere. This chapter covers defining functions, handling their arguments, scoping variables properly with local, and the idiomatic Bash way to actually produce data from a function.
Defining A Function
greet() {
echo "Hello!"
}
greetHello!This is the standard, most portable form. Bash also accepts an alternate syntax using the function keyword:
function greet {
echo "Hello!"
}Both define the function identically — this course uses the name() { } form throughout, since it’s the more universally recognized style, but you’ll see the function keyword version in other people’s scripts often enough to recognize it on sight.
A function has to be defined before it’s called — Bash reads top to bottom, and a call to a function that hasn’t been defined yet fails exactly like calling any other nonexistent command.
Function Arguments
Functions receive their own positional parameters — $1, $2, $#, "$@" — completely independent of whatever arguments the script itself was called with:
greet() {
echo "Hello, $1!"
echo "You gave $# argument(s) to this function."
}
greet "Alice" "extra"Hello, Alice!
You gave 2 argument(s) to this function.These positional parameters exist only for the duration of that function call — once it returns, $1, $2, and $# all revert back to whatever the script’s own positional parameters were before the function was called. A function’s arguments temporarily shadow the caller’s, rather than replacing or merging with them. Positional parameters are covered in the next section, just keep up with your excitement.
local Variables — Keeping Functions Self-Contained
Here’s the surprising default: a plain variable assignment inside a function is global unless you say otherwise:
counter=1
increment() {
counter=$((counter + 1))
}
increment
echo "$counter"2That’s sometimes intentional, but far more often it’s an accident waiting to happen — a function using a variable name that happens to collide with something in the caller’s scope, silently overwriting it. The local keyword scopes a variable to the function it’s declared in, restoring whatever value (or absence of one) existed in the outer scope once the function returns:
value="original"
overwrite_locally() {
local value="temporary"
echo "Inside function: $value"
}
overwrite_locally
echo "After function: $value"Inside function: temporary
After function: originalvalue inside the function is a completely separate variable from value outside it — the outer one was never touched.
Producing Data: return vs. echo/printf
This is the single biggest point of confusion for anyone arriving from another language: return in Bash does not return data. It sets the function’s exit status — a number from 0 to 255 — exactly the same mechanism exit uses for a whole script, covered back in Chapter 1. It cannot send back a string, and it cannot send back an arbitrary number outside that narrow range, as this chapter’s Shell-Safety Considerations section demonstrates concretely.
To actually produce a value from a function — a computed result, a string, anything beyond plain success/failure — the idiomatic Bash pattern is to have the function print the value, and have the caller capture it with command substitution:
uppercase() {
printf '%s' "${1^^}"
}
result=$(uppercase "hello")
echo "$result"HELLO${1^^} (parameter expansion for uppercasing) is covered in full in a later chapter — the point here is the pattern: the function’s job is to print its result, and $(...) at the call site captures exactly that output into a variable, the same way command substitution captures output from any other command.
Exit Codes From Functions
Separately from producing data, a function still has its own exit status, following exactly the same rule scripts do: it’s either the exit status of the function’s last command, or whatever was explicitly set with return:
file_exists() {
[[ -f "$1" ]]
}
if file_exists "/etc/hosts"; then
echo "Found it"
fiFound itfile_exists never calls return explicitly — its exit status is simply whatever [[ -f "$1" ]] produced, which is exactly what makes it usable directly as an if condition. This is the correct, idiomatic use of a function’s exit status: signaling success or failure, checked with if, &&, ||, or $? — never used to smuggle out a piece of data.
Sourcing Shared Function Libraries
Since functions are just shell code, they can live in a separate file and be loaded into any script that needs them with source, exactly as covered in Chapter 1:
cat > string-utils.sh << 'EOF'
uppercase() {
printf '%s' "${1^^}"
}
reverse_words() {
local -a words=($1)
local -a reversed=()
for (( i=${#words[@]}-1; i>=0; i-- )); do
reversed+=("${words[i]}")
done
echo "${reversed[@]}"
}
EOFsource string-utils.sh
uppercase "hello"A file like this is never meant to be executed directly — it has no reason to run standalone, so it typically doesn’t need a shebang or execute permission at all. Its only purpose is to be sourced, which loads its function definitions directly into whatever script sources it, exactly like the ~/.bashrc sourcing pattern from Chapter 4, just applied to your own reusable code instead of shell configuration.
Best Practices
- Use
localfor every variable a function doesn’t specifically intend to expose to the caller. Treat an un-local-ed assignment inside a function as a deliberate choice to modify the caller’s scope, not a default. - Never use
returnto try to send back data. Reserve it strictly for exit status — success or failure — and useecho/printfplus command substitution for anything that’s actually a value. - Give functions names that describe what they do, ideally in a way that reads naturally in an
ifcondition when the function’s purpose is a yes/no check (file_exists,is_valid, and so on). - Keep genuinely reusable functions in a separate sourced file, rather than copy-pasting the same function into every script that needs it.
Shell-Safety Considerations
Trying to return a computed number with return is the most common version of the data-vs-exit-status mistake, and it fails in a particularly sneaky way — not with an error, but with a silently wrong number:
add() {
return $(( $1 + $2 ))
}
add 200 100
echo "Result: $?"Result: 44200 + 100 is 300 — but exit statuses are constrained to the range 0–255, so Bash silently wraps the value with 300 % 256, producing 44. There’s no error, no warning — just a confidently wrong answer that looks completely plausible if you aren’t specifically checking the math.
The fix — produce the value with echo/printf and capture it via command substitution instead, exactly as covered earlier in this chapter:
add() {
echo $(( $1 + $2 ))
}
result=$(add 200 100)
echo "Result: $result"Result: 300No wraparound, because the value never had to pass through the 0–255-constrained exit-status mechanism at all.
The other common mistake is forgetting local on a variable whose name happens to already exist in the calling scope:
status="pending"
check_status() {
status="checking"
echo "Inside: $status"
}
check_status
echo "After: $status"Inside: checking
After: checkingThe caller’s status variable got silently overwritten, just because the function happened to reuse that exact name for its own internal bookkeeping. Adding local status="checking" inside the function fixes this the same way it did earlier in this chapter — the fix is identical, so it isn’t repeated as a second full example here, but the risk is worth calling out on its own: any un-local-ed variable name in a function is a potential silent collision with the caller’s variables, and the larger a script gets, the more likely that collision becomes.
Hands-On: Functions, Scoping, And Producing Real Data
1. Set up a working directory.
mkdir -p ~/shell-course/ch10
cd ~/shell-course/ch102. Define and call a basic function with arguments.
cat > demo.sh << 'EOF'
#!/usr/bin/env bash
greet() {
echo "Hello, $1!"
echo "This function received $# argument(s)."
}
greet "Alice" "extra"
EOF
chmod +x demo.sh
./demo.sh3. Confirm the global-leak problem, then fix it with local.
cat > scoping.sh << 'EOF'
#!/usr/bin/env bash
status="pending"
check_status_broken() {
status="checking"
}
check_status_fixed() {
local status="checking"
}
check_status_broken
echo "After broken call: $status"
status="pending"
check_status_fixed
echo "After fixed call: $status"
EOF
chmod +x scoping.sh
./scoping.sh4. Reproduce the return-wraparound bug, then fix it with echo and command substitution.
cat > add-broken.sh << 'EOF'
#!/usr/bin/env bash
add() {
return $(( $1 + $2 ))
}
add 200 100
echo "Result: $?"
EOF
chmod +x add-broken.sh
./add-broken.sh
cat > add-fixed.sh << 'EOF'
#!/usr/bin/env bash
add() {
echo $(( $1 + $2 ))
}
result=$(add 200 100)
echo "Result: $result"
EOF
chmod +x add-fixed.sh
./add-fixed.sh5. Build a small shared function library and source it.
cat > string-utils.sh << 'EOF'
uppercase() {
printf '%s\n' "${1^^}"
}
EOF
cat > use-library.sh << 'EOF'
#!/usr/bin/env bash
source string-utils.sh
uppercase "hello from a shared function"
EOF
chmod +x use-library.sh
./use-library.sh6. Clean up.
cd ~
rm -rf ~/shell-course/ch10You’ve now defined functions both syntaxes support, passed and read their arguments, contained a variable’s scope with local (and watched what happens without it), and — most importantly — confirmed exactly why return can’t carry data the way echo plus command substitution can. That closes out this section’s building blocks. The next chapter shifts into parameter expansion and input handling in depth, starting with positional parameters and special variables covered at the script level.