Skip to content

Functions

In the check_service.sh example from Conditions and Loops, the logic for checking a service's state was written for only one service. If several services need to be checked the same way, instead of rewriting that same handful of lines every time, they can be gathered into one place and called by name. That's exactly what a function does — a reusable, independently named block of code inside a script.

What You Will Learn

By the end of this article you will be able to:

  • use both syntaxes for declaring and calling a function;
  • pass arguments to a function and read them via $1, $@;
  • limit a variable's scope with the local keyword;
  • explain the difference between returning a result with return versus echo inside a function;
  • move functions into a separate file for reuse across scripts.

Declaring a Function

Bash has two equally valid syntaxes for declaring a function:

greet() {
    echo "Hello, Linux!"
}
function greet {
    echo "Hello, Linux!"
}

Both work identically; the first form is POSIX-compatible and more familiar across other shells, so it's more common. Calling a function is just like writing an ordinary command name, with no parentheses:

#!/bin/bash

greet() {
    echo "Hello, Linux!"
}

greet
Hello, Linux!

Warning

A function can only be called on lines that come after it's declared — Bash reads a script top to bottom and doesn't recognize a function until it reaches its definition. This is why functions are usually placed at the beginning of a script, before the main logic.

Passing Arguments to a Function

A function accepts positional parameters just like the script itself — but these parameters refer to the values given when the function is called, not the script's own $1, $2:

#!/bin/bash

greet() {
    echo "Hello, $1!"
}

greet "World"
greet "Ali"
Hello, World!
Hello, Ali!

$@ and $# work the same way inside a function too, just relative to the arguments passed to the function:

sum_all() {
    local total=0
    for number in "$@"; do
        total=$((total + number))
    done
    echo "$total"
}

sum_all 3 5 7
15

local: Limiting a Variable's Scope

By default in Bash, a variable declared inside a function is still considered global — it's visible throughout the whole script and can accidentally overwrite a same-named variable in another function or the main code:

#!/bin/bash

count=10

increment() {
    count=$((count + 1))
    echo "Inside the function: $count"
}

increment
echo "After the function: $count"
Inside the function: 11
After the function: 11

In this example, count is deliberately used as global (that's correct for cases where a function needs to affect outer state), but often a temporary variable inside a function shouldn't "leak" outward. The local keyword is used for this:

#!/bin/bash

count=10

increment() {
    local count=0
    count=$((count + 1))
    echo "Inside the function: $count"
}

increment
echo "After the function: $count"
Inside the function: 1
After the function: 10

count declared with local lives only inside the function and has no effect at all on any same-named variable outside it. total in the sum_all example above was declared local for exactly this reason — otherwise it could collide with a total variable in another function or the main script.

Tip

Practical rule: declare every variable created inside a function, and belonging only to that function, as local. This makes a function side-effect-free and predictable — anyone reading it can be confident it doesn't affect outside variables.

The local x=$(cmd) Trap

There's a subtle catch when local is combined with command substitution on the same line — and it's exactly why the project scripts later in this module split the two apart. Consider:

get_timestamp() {
    local ts=$(date +%s)   # looks fine...
    echo "$ts"
}

Here local is itself a command, and it runs after date, so it overwrites date's exit status with its own (local almost always succeeds). The failure of a command inside $( ) is silently swallowed — which defeats set -e and any if/exit-code check on that assignment. The fix is to declare first, assign second:

get_timestamp() {
    local ts
    ts=$(date +%s)         # exit status of date is now preserved
    echo "$ts"
}

This is why the Backup Script writes local timestamp on one line and timestamp=$(date ...) on the next, rather than combining them. shellcheck flags the single-line form as SC2155 for exactly this reason.

Tip

The two-line pattern only matters when you care whether the substituted command succeeded. For a value you fully trust (a literal, $1), local x="$1" on one line is fine. When in doubt — or under set -euo pipefail — split the declaration from the assignment.

Returning a Result: return vs. echo

A Bash function can return two different kinds of "result," and they're often confused:

return — only returns an integer from 0 to 255, i.e. the function's exit code. It signals a state (success/failure), not a value:

is_running() {
    systemctl is-active --quiet "$1"
    return $?
}

if is_running "nginx"; then
    echo "nginx is running"
fi

Actually, return $? is redundant here — systemctl is-active's own exit code, being the function's last command, automatically becomes the function's result, so return $? can be dropped entirely.

To return a text or numeric value, return isn't used (since it's limited to 0–255) — instead the function outputs the result with echo, and the caller reads it with command substitution:

free_disk_percent() {
    df --output=pcent / | tail -n 1 | tr -d '% '
}

percent=$(free_disk_percent)
echo "Free space: ${percent}%"
Free space: 42%

Don't mix the two in a single function: if a function needs to both output a value (echo) and return a status (return), it's usually best to output the value via echo and express success/failure through the exit code of the function's last command (or, separately, through a message written to standard error — stderr).

Moving Functions into a Separate File

Multiple scripts might use the same functions (for example, a logging or error-printing function). Instead of rewriting them in every script, they can be moved into a separate file and "included" into whichever scripts need them:

lib.sh:

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

backup.sh:

#!/bin/bash

source ./lib.sh

log "Backup started"
[2026-07-26 14:03:12] Backup started

source (shorthand: ., i.e. . ./lib.sh) executes the named file in the current shell's context — meaning functions and variables inside lib.sh become available exactly as if they were written directly in backup.sh. This is used to avoid repeating common helper functions across Backup Script and other project scripts.

Practical Scenario: a Function That Checks Multiple Services

Task: rewrite the single-service checking script from Conditions and Loops, using a function to check multiple services in sequence.

#!/bin/bash

check_service() {
    local service="$1"

    if systemctl is-active --quiet "$service"; then
        echo "OK: $service is running"
        return 0
    else
        echo "ERROR: $service is not running"
        return 1
    fi
}

failed=0

for service in nginx postgresql redis; do
    if ! check_service "$service"; then
        failed=$((failed + 1))
    fi
done

if [ "$failed" -gt 0 ]; then
    echo "$failed service(s) are not running"
    exit 1
fi

echo "All services are running"
OK: nginx is running
ERROR: postgresql is not running
OK: redis is running
1 service(s) are not running

Here, check_service describes the logic for checking a single service once, and the for loop calls it again for each service name — combining a function with a loop keeps the code considerably shorter and easier to read.

Common Mistakes

Calling a Function Before It's Declared

greet
greet() {
    echo "Hello!"
}
bash: greet: command not found

Bash reads top to bottom; if the function hasn't been defined yet, it knows nothing about it. Fix — place functions at the beginning of the script, before they're used.

Forgetting local and Accidentally Overwriting a Global Variable

If two functions use a same-named variable (for example, i or result) without local, they can unexpectedly change each other's values — a mistake that becomes especially hard to find in longer scripts.

Trying to Return Text via return

get_name() {
    return "Ali"
}
bash: return: Ali: numeric argument required

return only accepts an integer in the 0–255 range. For text, use echo and command substitution (see the free_disk_percent example above).

Confusing a Function's Output (echo) with Its Exit Code (return)

result=$(check_service "nginx")
if [ "$result" = "0" ]; then

This is wrong, because in the example above, check_service outputs an "OK: ..." message via echo, not text, and returns its exit code via return. To check the exit code, call the function directly inside the condition: if check_service "nginx"; then ....

Exercises

  1. Write a function named is_valid_ip that does a simple check that the string given as an argument consists of four dot-separated numbers (no need to handle complex CIDR or edge cases), and returns 0 or 1 accordingly.
  2. Write a function named to_upper that converts its $1 argument to uppercase and prints it via echo (you can use the ${1^^} parameter expansion).
  3. Move the check_service function from the practical scenario above into a separate file named lib.sh and link it into the main script with source.
  4. Compare two functions, one written without local and one with — use a same-named variable in both and observe the difference in the result.

Summary

Functions gather repeated logic under a single name, shortening a script and making it easier to read. local scopes a variable to inside the function, preventing accidental effects on outer state; return is only for an exit code, while echo combined with command substitution is used to return a value. The next topic, Exit Codes and Error Handling, covers the full logic behind these exact exit codes, detecting failure ahead of time, and stopping a script cleanly when an error occurs.

References