Bash Basics #4: Functions in Bash

Structure and modularize your Bash scripts with functions: declaration, parameter passing, return values (return vs. echo), local variable scopes, and function libraries.

Reading time: 20 min

Welcome to the fourth part of our technical series on Linux and Bash programming!

In the first three modules of our basics course, we learned how to create and secure scripts, how variables and parameter expansions work, and how to control program flow through control structures and loops.

However, as script complexity grows, unmaintainable, redundant code ("spaghetti code") quickly emerges. When the same validation, the same log entry, or the same database query is repeated in ten different places, errors creep in and maintenance becomes a nightmare. The solution in professional scripting is: modularization through functions (DRY principle: Don't Repeat Yourself).

In this lesson, you will learn how to cleanly declare Bash functions, pass arguments, encapsulate variables with local to protect against global side effects, master the fundamental difference between return (status codes) and echo (data return), and create your own reusable function libraries.

Declaration and Calling Bash Functions

A function is a named, reusable code block within your script. For the calling shell, it behaves almost exactly like a standalone command or a shell built-in.


┌─────────────────────────────────────────────────────────────┐
│                 CONTROL FLOW ON FUNCTION CALLS               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  [ Main program ]                                           │
│  Command A                                                  │
│  ├── Call: log_info "Start" ──┐                             │
│  │                            ▼                             │
│  │                  [ Function: log_info() ]                │
│  │                  - Reads parameter $1                     │
│  │                  - Outputs timestamp & text               │
│  │                  - return 0                               │
│  │                            │                             │
│  ◄────────────────────────────┘                             │
│  Command B (continues)                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Syntax Forms Compared

Bash provides three different ways to define a function:


# 1. POSIX-compliant syntax (RECOMMENDED):
my_function() {
    echo "Executing the function..."
}

# 2. Using the 'function' keyword (Bash-specific):
function my_function {
    echo "Executing the function..."
}

# 3. Combination (Bash-specific, redundant):
function my_function() {
    echo "Executing the function..."
}

💡 Best practice: Use the standard form functionname() { ... }. It is short, readable, 100% POSIX-compliant, and works seamlessly in Bash, Dash, and Zsh.

Calling a Function

Unlike programming languages such as Python, C, or PHP, functions in the shell are called without parentheses:


# Definition
greet() {
    echo "Hello from the function!"
}

# Call (simply the name like a normal Linux command):
greet

Parameter Passing: Processing Arguments in Functions

Bash functions have their own isolated positional parameters. When you pass arguments to a function, $1, $2, $#, and "$@" within the function body are temporarily populated with the values of the function call – independent of the original script arguments.


#!/usr/bin/env bash
set -euo pipefail

# Function expects 2 parameters: first name and last name
greeting() {
    local firstname="$1"
    local lastname="$2"
    
    echo "Hello $firstname $lastname! Great to have you here."
    echo "Number of parameters passed to the function: $#"
}

# Call with two arguments:
greeting "Max" "Mustermann"

Important Parameter Variables Within Functions:

Variable Meaning in Function Context
$1, $2 .. $9 First, second to ninth argument of the function
${10}, ${11} Arguments from position 10 onwards (curly braces mandatory!)
$# Number of all arguments passed to the function
"$@" All function arguments as separately protected parameters
$FUNCNAME Name of the currently executed function (ideal for logging)

Processing Parameters Sequentially with shift

With the shift command, you shift the parameter list one position to the left ($2 becomes $1, $3 becomes $2, etc.):


copy_files() {
    local target_folder="$1"
    shift # Remove first argument (target folder)
    
    # The rest in "$@" are now all source files:
    for file in "$@"; do
        echo "Copying $file to $target_folder..."
        cp "$file" "$target_folder/"
    done
}

copy_files "/backup" "app.log" "db.sql" "config.ini"

Scope: Enforcing Local Variables with local

In Bash, variables are always global by default. If you declare a variable inside a function without the local keyword, you silently overwrite same-named variables in the main program!


┌─────────────────────────────────────────────────────────────┐
│                 SCOPING MATRIX: GLOBAL VS. LOCAL             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  [ Script level ]                                           │
│  STATUS="ONLINE"                                            │
│         │                                                   │
│         ├── Function call: check()                          │
│         │     ├── local TEMP="123" ──> Only visible HERE    │
│         │     └── STATUS="OFFLINE" ──> Globally modified    │
│         │                                                   │
│         ▼                                                   │
│  After the function:                                        │
│  ├── TEMP is deleted (no longer exists)                     │
│  └── STATUS is now permanently "OFFLINE" (bug source!)      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Practical Example: Avoiding Fatal Side Effects


#!/usr/bin/env bash
set -euo pipefail

# Global counter variable in main program
i=100

counter_function() {
    # WITHOUT 'local', the global variable $i would be set to 0 here!
    local i=0
    while [[ $i -lt 3 ]]; do
        echo "Function loop: $i"
        (( i++ ))
    done
}

echo "Before: i = $i"   # Outputs 100
counter_function
echo "After: i = $i"    # Still outputs 100 (thanks to 'local'!)

⚠️ Safety rule: Within functions, declare every helper variable with local without exception. This prevents unpredictable bugs in larger scripts.

Return Values: return (Status Codes) vs. echo (Data Return)

One of the biggest misunderstandings for Bash beginners is how return works:

  • return [0-255]: Returns no text data, only a numeric exit status code (0 to 255) to the shell.
  • Returning data/results: Done via standard output (echo) and captured with command substitution $(function).

┌─────────────────────────────────────────────────────────────┐
│                 RETURN METHODS COMPARED                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Method 1: return (status code for true/false)              │
│  ├── Range: 0 (success) to 255 (error)                      │
│  └── Use: Direct use in if conditions                       │
│                                                             │
│  Method 2: echo + command substitution (text / data)        │
│  ├── Range: Any strings, numbers, arrays                    │
│  └── Use: RESULT=$(my_function "arg")                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

1. return for Status Queries (Predicate Functions)

Functions that perform logical checks use return 0 for success (true) and return 1 for error (false):


# Check if the script is running with root privileges
is_root() {
    if [[ $EUID -eq 0 ]]; then
        return 0 # True / Success
    else
        return 1 # False / Error
    fi
}

# Direct use in an if branch:
if is_root; then
    echo "Script is running as administrator (root)."
else
    echo "Error: This script requires root privileges!" >&2
    exit 1
fi

2. Returning Data & Values via Command Substitution

When a function should produce a string or calculation result, it outputs the value via echo:


calculate_net() {
    local gross="$1"
    local tax_rate="$2"
    
    # Mathematical integer calculation
    local net=$(( gross * 100 / (100 + tax_rate) ))
    
    echo "$net" # Text output on stdout
}

# Capture result in a variable:
NET_AMOUNT=$(calculate_net 119 19)
echo "Net amount: ${NET_AMOUNT} EUR"

Modularization: Including Function Libraries with source

In professional Linux environments, general helper functions (like logging, configuration parsing, or network checks) are outsourced into separate library files and loaded into scripts via source (or .).

1. Create a Logging Library (lib/logging.sh):


# lib/logging.sh - Reusable logging library

log_info() {
    echo -e "\e[1;32m[INFO]\e[0m $(date '+%Y-%m-%d %H:%M:%S') - $*"
}

log_warn() {
    echo -e "\e[1;33m[WARN]\e[0m $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2
}

log_error() {
    echo -e "\e[1;31m[ERROR]\e[0m $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2
}

2. Include the Library in the Main Script (app.sh):


#!/usr/bin/env bash
set -euo pipefail

# Determine absolute path of the script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source the library
# shellcheck source=lib/logging.sh
source "$SCRIPT_DIR/lib/logging.sh"

log_info "Starting application backup..."
log_warn "Disk space on /backup is 85% full."
log_error "Could not establish database connection!"

Recursive Functions in Bash

A function can call itself (recursion). The key here is always a clear termination condition to avoid an infinite loop and stack overflow.

Example: Calculating Factorial ($n!$)


factorial() {
    local n="$1"
    
    # Termination condition: 0! = 1 and 1! = 1
    if [[ $n -le 1 ]]; then
        echo 1
        return 0
    fi
    
    # Recursive call: n * factorial(n - 1)
    local previous
    previous=$(factorial $(( n - 1 )))
    echo $(( n * previous ))
}

echo "Factorial of 5 is: $(factorial 5)"  # 120

Exercises & Practice Check

Practice tasks for lesson #4:

  1. Task 1 (Host Reachability Checker):

Write a function check_host() that accepts a hostname or IP as parameter $1, executes ping -c 1 -W 2 "$1", and returns return 0 on success or return 1 if unreachable.

  1. Task 2 (Universal Calculator):

Create a function calc() that expects three arguments (Number1, Operator, Number2), performs the operation (+, -, *, /) with case, and returns the result via echo. Catch division by zero with an error message and return 1.

  1. Task 3 (String Trimmer):

Write a function trim() that removes leading and trailing whitespace from a passed string.

Sample Solution for Task 2:


#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

calc() {
    local a="${1:?First number missing}"
    local op="${2:?Operator missing (+, -, *, /)}"
    local b="${3:?Second number missing}"
    
    case "$op" in
        +)
            echo $(( a + b ))
            ;;
        -)
            echo $(( a - b ))
            ;;
        \*)
            echo $(( a * b ))
            ;;
        /)
            if [[ $b -eq 0 ]]; then
                echo "Error: Division by zero not allowed!" >&2
                return 1
            fi
            echo $(( a / b ))
            ;;
        *)
            echo "Error: Unknown operator '$op'!" >&2
            return 1
            ;;
    esac
}

# Test run
echo "Result: $(calc 42 + 8)"
echo "Result: $(calc 100 / 4)"

Command Reference (Cheatsheet)

Syntax / Command Description & Function
name() { ... } Standard function declaration in Bash
name "arg1" "arg2" Function call with two arguments (without parentheses!)
local VAR="value" Declares a variable with local function scope
$1, $2, "$@" Access to the function's positional parameters
shift Shifts parameter list one position to the left
return 0 Ends the function with success status (0 to 255)
return 1 Ends the function with error status
VAR=$(func) Captures text output (stdout) of the function into variable
source lib.sh Sources external function library into the script
$FUNCNAME Contains the name of the currently executed function

Further Resources

Resource Description
GNU Bash Shell Functions Official GNU reference on function declaration and scopes
Bash Basics #3: Control Structures The previous part: Branching, tests, and loops
Linux Command Line Processor Guide Fundamental knowledge on shells, I/O streams, and pipes
chmod and File Permissions Linux permission concepts in detail

Conclusion

Functions are the decisive step from simple linear scripts to professional software architecture in Linux administration. Through clean encapsulation of variables with local, clear separation of error return codes (return) and data (echo), and outsourcing into modular libraries, your scripts become clear, robust, and future-proof.

💡 Tip: Create a central lib/ folder for recurring functions like logging, configuration checks, or user validations. This saves valuable time with every new script and standardizes error handling across your servers.

In the next lesson, we focus on how your scripts communicate with the outside world: 👉 Next up: Bash Basics #5: Input and Output in Bash

👉 Course overview: All lessons of the Bash basics course

Share & export

Export as Markdown