Right, so we’ve been throwing commands at the terminal one by one like we’re paying by the line. It’s time to graduate. Functions are how we stop repeating ourselves and start building actual tools, not just one-liner party tricks. Think of them as your own custom commands. You define them once, give them a name, and then you can use them anywhere in your script. It’s the difference between duct-taping pieces together and actually owning a toolbox.

Defining a Function: The Two (Silly) Syntaxes

Bash, in its infinite wisdom, gives you two ways to define a function. They do the exact same thing, which is frankly absurd, but here we are. I strongly prefer the first one; it looks more like a proper programming language and avoids some weird edge cases with the second.

Syntax 1 (The Sensible One):

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

Syntax 2 (The Old School One):

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

See? The function keyword is optional. I always use it for clarity, but it’s a holy war among shell scripters. Pick one and stick with it. To call your new masterpiece, you just use its name: greet "Reader". The $1 inside the function grabs its first argument.

The Critical local Keyword

This is the single most important concept to grasp with bash functions, and if you ignore it, you will get burned. Variables in bash are global by default. Let that sink in. Every variable you set inside a function is visible and changeable by the entire script.

This is a terrible, horrible, no-good design decision. It’s like every room in your house sharing a single, giant junk drawer. You change something in one function, and it silently breaks something in a completely different part of your script. Chaos.

The local keyword is your savior. It scopes a variable to the function and its children.

function demo_scope() {
    local local_var="I'm safe inside here"
    global_var="I'm trampling through your script's global state!"

    echo "Inside function: local_var = $local_var, global_var = $global_var"
}

demo_scope
echo "Outside function: local_var = $local_var, global_var = $global_var"

Run that. You’ll see local_var is empty outside the function, while global_var is now set for everyone. Always use local for variables you only need inside the function. It’s not just a best practice; it’s a necessity for writing sane, predictable scripts.

“Returning” Values: It’s a Trap!

Bash functions don’t return values like in other languages. They can only do two things:

  1. Echo output: You “return” a value by printing it to stdout (echo or printf). The caller then captures this with command substitution $( ).
  2. Return an exit status: A number from 0-255, where 0 means success and anything else means failure. This is done with the return keyword or by exiting the function with the final command’s status.

Let’s untangle this mess.

Returning Data (The Echo Method):

function get_os() {
    local os_name
    # Let's be fancy and check for Linux or macOS
    if [[ "$OSTYPE" == "linux-gnu"* ]]; then
        os_name="Linux"
    elif [[ "$OSTYPE" == "darwin"* ]]; then
        os_name="macOS"
    else
        os_name="Unknown"
    fi
    echo "$os_name" # This is how we "return" the string
}

# Capture the echoed output into a variable
my_os=$(get_os)
echo "You are running $my_os"

Returning an Exit Status (The Actual Return Method): This is for signaling success or failure, not for passing data. It works exactly like a script or command exiting.

function is_file_readable() {
    local file_path=$1
    if [[ -r "$file_path" ]]; then
        return 0 # Success! The file is readable.
    else
        return 1 # Failure. It is not.
    fi
}

# Use it in a conditional
if is_file_readable "/etc/hosts"; then
    echo "We can read the hosts file! Proceeding..."
else
    echo "Nope, no permissions. Aborting." >&2
    exit 1
fi

The crucial pitfall here is mixing up the two. If you echo error messages and then return 1, the caller capturing your output with $( ) will get your error message as part of its data! For functions that return status, all informational output should go to stderr (>&2).

function might_fail() {
    if [[ $1 -eq 1 ]]; then
        echo "Something bad happened on stderr" >&2
        return 1
    else
        echo "Useful data on stdout"
        return 0
    fi
}

# Correct usage: capture stdout, let stderr print to terminal
output=$(might_fail 1) # The error message will still be visible
echo "The exit code was $?"
echo "The output was '$output'"

This is clunky, but it’s the way the shell works. You have to be explicit about what’s data and what’s a log message. Mastering this distinction is what separates a shell script that works from one that’s actually robust.