Functions

Functions

Bitnesia • Aug 30, 2026 89 •ID

The kompres_log.sh script that we constructed at the end of Chapter 7 is already quite long for a single task. Imagine if the logic for checking empty files, compressing, and logging results needed to be reused across three other scripts: a daily backup script, a weekly log rotation script, and a maintenance script executed manually. Copying and pasting the same code block into multiple files is clearly not a good solution, because as soon as a bug arises or adjustments are needed, we would have to fix it one by one in every copy. This chapter discusses functions, named code blocks that can be called repeatedly whenever needed, both from other parts of the same script and from separate scripts via the source mechanism which will be discussed further in Chapter 26. We will start with defining and calling functions, move on to passing arguments into them, explore the distinction between return value and function output that frequently confuses beginners, and conclude with the scope of local versus global variables inside functions, an essential foundation to prevent written functions from silently corrupting variables in other parts of the script.

8.1 Defining and Calling Functions

This section discusses the two syntaxes available in Bash to define functions, as well as how to call them once defined.

8.1.1 Basic Syntax of Function Declarations

Bash provides two syntax forms for defining functions, both of which yield identical behavior. The first form, which is recommended because it adheres to the POSIX shell standard, writes the function name followed by empty parentheses, then the command block inside curly braces.

function_name() {
    first_command
    second_command
}

The second form uses the function keyword as an explicit marker, a Bash-specific feature (and some ksh derivatives) that is not recognized by pure POSIX sh, just like the [[ ]] status discussed in Chapter 6.1.4.

function function_name {
    first_command
    second_command
}

Both forms can be combined simultaneously (function function_name()), but this combination is purely redundant without additional benefit, so it should be avoided. In practice, the function_name() { ... } form without the function keyword is more commonly used because it is portable to sh, unless it is guaranteed that the script only runs on Bash and the function keyword is considered to make the code intent more explicit to read. Let us try defining a first function that displays a simple greeting message.

greet_user() {
    echo "Hello, maintenance script started"
}

Technically, the Bash parser is very lenient regarding the characters allowed for function names, even permitting names starting with a digit, far more relaxed than the variable naming rules discussed in Chapter 3.1. This leniency should not be exploited arbitrarily for the sake of readability and portability. The widely accepted convention among Bash script authors is to follow the same rules as variable names: only letters, numbers, and underscores, and not starting with a digit, so that function names remain easy to read and safe if the script ever needs to be adapted to POSIX sh, which has stricter naming rules. In practice, lowercase snake_case conventions such as check_service_status are most commonly used to remain consistent with variable naming conventions.

8.1.2 Calling Functions

Once defined, a function is called simply by typing its name just like invoking a regular command, without parentheses and without any special prefix.

greet_user() {
    echo "Hello, maintenance script started"
}

greet_user

Functions can be invoked repeatedly from anywhere within the script, including from within conditional structures (Chapter 6) or loops (Chapter 7) that we have studied, precisely fulfilling the main benefit of functions: write logic once, execute repeatedly.

check_service() {
    echo "Checking service status..."
    systemctl is-active nginx
}

for attempt in 1 2 3; do
    check_service
    sleep 5
done

To ensure a function is truly recognized by the active shell before being invoked, especially in long scripts with many functions, the type builtin can be used to inspect it directly from the terminal, and declare -f to display its complete definition content.

type greet_user
declare -f greet_user

type greet_user shows that greet_user is a function along with its complete definition content if recognized, or a message stating that the command was not found if it is not. This method is useful to verify that a function was successfully loaded, especially when the function is defined via source from another file (Chapter 26), rather than directly inside the same script.

8.1.3 Troubleshooting: Functions Must Be Defined Before Invocation

Bash reads and executes scripts sequentially from top to bottom, and a function definition is only registered to the shell once its definition line is actually executed, not at the beginning when the script is first opened. Consequently, calling a function before its definition appears in the execution flow will fail.

greet_user

greet_user() {
    echo "Hello, maintenance script started"
}

The script above produces the error greet_user: command not found because when the first line executes, Bash has not seen the definition of the greet_user function at all. This differs from other programming languages that perform function declaration hoisting. The recommended standard in the field is to place all function definitions at the top of the script, right after the shebang and configuration variables, followed by the main section that calls those functions at the bottom, a pattern we will use again when constructing structured scripts in Chapter 26.

#!/bin/bash

greet_user() {
    echo "Hello, maintenance script started"
}

greet_user

8.2 Parameters and Function Arguments

This section discusses how to pass data into functions via arguments using the same positional parameter mechanism as script arguments ($1, $2, etc.), a pattern briefly encountered via ${1:-status} in the Chapter 6.4.4 case study and discussed further for script contexts in Chapter 12.

8.2.1 Accessing Arguments with $1, $2, and Beyond

Arguments are passed to functions just like invoking regular commands, separated by spaces after the function name, without parentheses. Inside the function, those arguments are accessed via variables $1, $2, and so on, according to their positional order.

check_service() {
    echo "Checking service status: $1"
    systemctl is-active "$1"
}

check_service "nginx"
check_service "mysql"

An important detail to understand: $1, $2, and so on inside a function are positional parameters belonging to that function itself, separate from the overall script positional parameters (which are the arguments passed when the script is invoked from the terminal, discussed in Chapter 12). While the function is running, the value of $1 inside it refers to the function's first argument, not the script's first argument, even if the function is called from inside a script that also receives its own arguments.

display_function_args() {
    echo "Function argument: $1"
}

echo "Script argument: $1"
display_function_args "function_specific_value"
echo "Script argument after function completion: $1"

Executing the script above with ./example.sh script_argument displays Script argument: script_argument, then Function argument: function_specific_value, and finally Script argument after function completion: script_argument. The script positional parameters are neither overwritten nor lost after function execution finishes; Bash automatically restores them once the function completes.

8.2.2 $@ and $# for Flexible Arguments

Just like with scripts, functions can accept an unspecified number of arguments from the start using $@ (all arguments as separate list items) and $# (the total number of arguments). This is useful when a function needs to operate on a list of items of varying length, such as checking multiple services at once in a single invocation.

check_multiple_services() {
    echo "Total services checked: $#"
    for service in "$@"; do
        echo "Checking: $service"
        systemctl is-active "$service"
    done
}

check_multiple_services "nginx" "mysql" "redis"

The for item in "$@" pattern above is identical to the pattern used to iterate over arrays in Chapter 7.1.4, and both work for the exact same reason: quoted "$@" always treats each argument as a separate item, even if the argument itself contains spaces.

8.2.3 $@ vs $*: Differences That Must Be Quoted Correctly

Just like the discussion on word splitting in Chapter 3.4 and Chapter 7.1.5, $@ and $* appear similar but behave differently when placed inside double quotes. "$@" expands each argument as a separate word, whereas "$*" combines all arguments into a single string, separated by the first character of the IFS variable (Internal Field Separator, the shell word separator variable discussed in Chapter 7.2.3), typically a space.

display_arguments() {
    echo "--- Using \"\$@\" ---"
    for a in "$@"; do
        echo "Item: $a"
    done

    echo "--- Using \"\$*\" ---"
    for a in "$*"; do
        echo "Item: $a"
    done
}

display_arguments "january report.txt" "february report.txt"

The first loop displays two lines of Item, one for each argument, as typically expected. The second loop displays only one line of Item containing both arguments joined into a single space-separated string, because "$*" is designed for cases requiring all arguments as a single string representation, such as when displaying log messages. For iterating through each argument individually, quoted "$@" is almost always the correct choice.

Form (Quoted)Expansion ResultBest Suited For
"$@"Each argument as a separate wordIterating through arguments one by one, for example with for
"$*"All arguments joined into one string, separated by IFSDisplaying all arguments as a single line of text, for example log messages

8.2.4 Troubleshooting: Empty Arguments and Argument Count Validation

A function accessing $1 without verifying whether the argument was actually passed will evaluate to an empty string rather than an error, a behavior that can introduce silent bugs if unhandled.

check_service() {
    systemctl is-active "$1"
}

check_service

Invoking check_service without arguments above causes $1 to contain an empty string, executing systemctl is-active "" with an empty argument, which most likely generates an error from systemctl itself, not from Bash. The recommended practice is to validate the argument count on the first line of the function using $# and conditional operators from Chapter 6, then exiting early with a clear message if arguments are incomplete.

check_service() {
    if [ "$#" -lt 1 ]; then
        echo "Error: check_service requires 1 argument (service name)" >&2
        return 1
    fi
    systemctl is-active "$1"
}

The return statement used above will be thoroughly covered in the next section. This argument validation pattern is very common among Sysadmins in utility functions used repeatedly across scripts, as detecting failures early with clear messages is far better than causing confusing errors mid-execution.

8.3 Return Value vs Function Output

This section addresses one of the most common sources of confusion for Bash beginners: functions in Bash do not return values in the same manner as functions in other programming languages (Python, JavaScript, and similar). There are two distinct mechanisms that are frequently confused: return value via the return statement and function output via echo or printf.

8.3.1 return and Function Exit Status

The return statement sets the exit status of a function, an identical concept to regular command exit status discussed in Chapter 6.4.1, rather than returning arbitrary data values. Just like exit, the numeric value sent by return ranges from 0 to 255, and values outside that range are automatically truncated modulo 256, following standard exit status behavior (Chapter 6.4.1). return is also only valid when called from within a function (or a sourced script, covered in Chapter 26).

is_service_active() {
    if systemctl is-active --quiet "$1"; then
        return 0
    else
        return 1
    fi
}

if is_service_active "nginx"; then
    echo "nginx is running"
else
    echo "nginx is inactive"
fi

Note that the is_service_active function above is used directly as an if condition, just like testing a regular command exit status such as [ -f file ] in Chapter 6. This is because if tests the exit status of whichever command it executes, including function calls. If return is called without a numeric argument, the function returns the exit status of the last command executed inside its body, mimicking the default behavior of a Bash script that does not explicitly execute exit.

is_service_active() {
    systemctl is-active --quiet "$1"
}

The concise form above behaves identically to the previous version using explicit if and return statements, because the exit status of systemctl is-active --quiet itself is 0 when the service is active and non-zero when inactive, and that exact value is automatically passed along as the function exit status.

8.3.2 Returning Data via Command Substitution

Because return is limited to numbers from 0 to 255 and is only suited for indicating success/failure status, functions needing to return actual data (strings, calculation results, file paths, and similar) use a different pattern: the function outputs that data to stdout via echo or printf, and the calling code captures that output using command substitution $() discussed in Chapter 3.4.

get_server_time() {
    date "+%Y-%m-%d %H:%M:%S"
}

current_time=$(get_server_time)
echo "Current server time: $current_time"

This pattern can be used for more complex data, such as returning the filename produced by a process, which can then be directly consumed by another command in the calling code.

generate_backup_name() {
    local prefix="$1"
    echo "${prefix}_$(date +%Y%m%d).tar.gz"
}

filename=$(generate_backup_name "app")
echo "Backup filename: $filename"
tar -czf "$filename" /var/www/app

A critical trap to watch out for: anything displayed by the function to stdout (including lingering debug echo statements) will be captured by command substitution in the calling code, not just the single output line intended as the result. For debug or progress messages that should not be captured, redirect them to stderr using >&2 redirection covered in Chapter 5.2 and Chapter 5.3.

generate_backup_name() {
    local prefix="$1"
    echo "Preparing backup filename..." >&2
    echo "${prefix}_$(date +%Y%m%d).tar.gz"
}

filename=$(generate_backup_name "app")
echo "Backup filename: $filename"

Because the message Preparing backup filename... is redirected to stderr, it still prints to the terminal when the script runs, but it does not get captured inside the filename variable, which remains clean with only the filename output from the final echo.

8.3.3 When to Use return vs Output

In practice, the rule of thumb is simple: use return when the function only needs to report success or failure (used as an if or while condition, or combined with &&/|| operators from Chapter 6.4.2), and use echo plus command substitution when the function needs to produce data for further use by the calling code. Both patterns can also be combined within the same function: exit status indicates whether the process succeeded, while output contains the resulting data.

AspectreturnOutput (echo/printf)
Value TypeNumber 0-255 (exit status)Any arbitrary string/data
Retrieval Method$?, or directly as an if/while conditionCommand substitution $()
Primary PurposeReporting success/failureReturning processed data results
get_server_ip() {
    local result
    result=$(dig +short "$1" 2>/dev/null)

    if [ -z "$result" ]; then
        return 1
    fi

    echo "$result"
    return 0
}

if ip=$(get_server_ip "example.com"); then
    echo "IP found: $ip"
else
    echo "Failed to resolve IP for domain" >&2
fi

Notice that calling ip=$(get_server_ip "example.com") above can still be evaluated directly as an if condition, because command substitution inherits the exit status of the command (in this case, the function) executed inside it. This combined status and output pattern will be frequently used when constructing utility functions for input validation in Chapter 12 and error handling in Chapter 25.

8.4 Local vs Global Variables in Functions

This closing section discusses variable scope inside Bash functions, a topic that often causes hidden bugs if not understood early, as Bash default behavior differs from most modern programming languages.

8.4.1 Variables are Global by Default

Unlike languages like Python or JavaScript that default to local scope for variables assigned inside functions, Bash treats every variable created inside a function as a global variable unless explicitly declared otherwise. This means variables set inside a function persist and remain accessible (and subject to being overwritten) outside the function after execution finishes.

calculate_total() {
    total=100
    echo "Total calculated inside function: $total"
}

total=5
calculate_total
echo "Total after function call: $total"

The last line outputs Total after function call: 100, rather than 5 as beginners accustomed to other languages might expect. The total variable set inside calculate_total overwrites the existing total variable outside the function, because they are actually the exact same variable, not two separate variables sharing a name by coincidence.

8.4.2 Declaring Local Variables

The local keyword, which can only be used inside functions, declares a variable whose scope is restricted to that function (along with any other functions called from within it, discussed in the next section), and automatically disappears once the function completes execution without affecting same-named variables outside.

calculate_total() {
    local total=100
    echo "Total calculated inside function: $total"
}

total=5
calculate_total
echo "Total after function call: $total"

After adding local, the last line outputs Total after function call: 5; the original value is preserved unchanged because total inside the function and total outside are now separate variables. local can be combined with initial value assignments on a single line as shown above, declared prior to assignment, or used to declare multiple local variables at once.

process_data() {
    local name status
    name="$1"
    status="active"
    echo "$name: $status"
}

local can also be combined with relevant declare options, such as local -a for local arrays (Chapter 4.1) or local -i for variables evaluated as arithmetic expressions (Chapter 3.3), providing stricter type control for variables that are only relevant within that function.

list_failed_services() {
    local -a failed=()
    for s in "$@"; do
        if ! systemctl is-active --quiet "$s"; then
            failed+=("$s")
        fi
    done
    echo "${failed[@]}"
}

A widely accepted best practice in the field is to always declare variables that are meant solely for internal function use as local, unless there is a specific reason to make them global. This habit prevents functions from accidentally leaking or overwriting variables in other parts of the script, especially in larger scripts maintained by multiple engineers.

8.4.3 Troubleshooting: Local and Dynamic Scope

A technical detail often overlooked: local scoping in Bash is dynamic (dynamic scope), rather than lexical scope found in most modern languages. This means a local variable is not restricted strictly to the function where it was declared, but remains visible to any other function called from within that function while that call stack is active.

inner_function() {
    echo "Message value in inner_function: $message"
}

outer_function() {
    local message="hello from outer_function"
    inner_function
}

outer_function

Even though message was declared local inside outer_function and inner_function does not define a variable named message, calling inner_function above still outputs Message value in inner_function: hello from outer_function because inner_function is called from inside the execution scope of outer_function while its local variables are active. Once outer_function completes execution completely, the message variable disappears, and invoking inner_function independently outside outer_function will not see the message variable at all.

While this behavior rarely presents issues in everyday scripting, understanding it is important when building modular functions that call each other. Giving local variables descriptive names (for example, local active_service_name instead of local result) reduces the risk of accidental naming collisions between nested function calls.