LinuxBash Scripting

Bash Functions, Control Flow, and Exit Codes

Write reusable Bash functions with local scope, proper exit codes, and return values. Master if, case, for, while, and until control flow. Lesson 5 of the Linux & Bash Scripting course.

TT
Daniel Brooks
6 min read
Bash Functions, Control Flow, and Exit Codes

Functions transform a script from a linear sequence of commands into a composable collection of named operations. Combined with clean control flow and disciplined use of exit codes, they are the foundation of scripts that are readable, testable, and reusable across projects.

Previous: Lesson 4 — Variables, Arrays, and String Manipulation


Defining and Calling Functions

bash
#!/usr/bin/env bash

# Two equivalent syntaxes — prefer the second for clarity
function greet { echo "Hello, $1"; }
greet() { echo "Hello, $1"; }

# Multi-line function
deploy_service() {
    local service_name="$1"
    local version="$2"

    echo "Deploying ${service_name} v${version}..."
    systemctl restart "${service_name}"
}

# Call like a command
deploy_service "nginx" "1.24"

Functions must be defined before they are called. Organise scripts with all function definitions at the top and a main() function called at the bottom.


Return Values and Exit Codes

Bash functions do not return values in the traditional sense. They return an exit code (0–255). To pass data back to the caller, use stdout capture or a global variable:

bash
#!/usr/bin/env bash

# Pattern 1: return via stdout (captured by caller)
get_timestamp() {
    date '+%Y-%m-%d_%H:%M:%S'
}
ts=$(get_timestamp)
echo "Timestamp: $ts"

# Pattern 2: return via a named output variable (nameref — Bash 4.3+)
get_free_port() {
    local -n _result="$1"   # nameref — alias for the caller's variable
    _result=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()")
}
get_free_port my_port
echo "Free port: $my_port"

# Pattern 3: return exit code to signal success/failure
file_exists() {
    [[ -f "$1" ]]
}

if file_exists "/etc/nginx/nginx.conf"; then
    echo "Config found"
else
    echo "Config missing" >&2
    exit 1
fi

Conditionals

[[ ]] vs [ ] vs (( ))

SyntaxUse for
[[ ]]String and file tests — always prefer over [ ] in Bash
[ ]POSIX portable tests — needed only when writing sh-compatible scripts
(( ))Integer arithmetic tests
bash
# String tests
[[ -z "$str" ]]           # true if empty
[[ -n "$str" ]]           # true if non-empty
[[ "$a" == "$b" ]]        # string equality
[[ "$a" != "$b" ]]        # string inequality
[[ "$str" == *"pattern"* ]] # glob match (no quotes around pattern)
[[ "$str" =~ ^[0-9]+$ ]]  # regex match (ERE, unquoted)

# File tests
[[ -f "$path" ]]          # regular file exists
[[ -d "$path" ]]          # directory exists
[[ -r "$path" ]]          # readable
[[ -w "$path" ]]          # writable
[[ -x "$path" ]]          # executable
[[ -s "$path" ]]          # non-empty file
[[ -L "$path" ]]          # symbolic link

# Integer tests (inside [[ ]])
[[ "$a" -eq "$b" ]]       # equal
[[ "$a" -ne "$b" ]]       # not equal
[[ "$a" -lt "$b" ]]       # less than
[[ "$a" -ge "$b" ]]       # greater than or equal

# Integer tests (inside (( )) — cleaner for arithmetic)
(( a == b ))
(( a < b ))
(( a >= 100 ))

if / elif / else

bash
#!/usr/bin/env bash

check_service() {
    local service="$1"

    if systemctl is-active --quiet "$service"; then
        echo "${service}: running"
    elif systemctl is-enabled --quiet "$service"; then
        echo "${service}: stopped (but enabled)"
    else
        echo "${service}: stopped and disabled"
    fi
}

check_service nginx
check_service postgresql

case

case is cleaner than a long if/elif chain when matching a variable against multiple string patterns:

bash
#!/usr/bin/env bash

deploy() {
    local env="$1"

    case "$env" in
        production|prod)
            echo "Deploying to production"
            export REPLICAS=5
            export LOG_LEVEL="warn"
            ;;
        staging|stage)
            echo "Deploying to staging"
            export REPLICAS=2
            export LOG_LEVEL="info"
            ;;
        development|dev|local)
            echo "Deploying locally"
            export REPLICAS=1
            export LOG_LEVEL="debug"
            ;;
        *)
            echo "Unknown environment: $env" >&2
            return 1
            ;;
    esac
}

Loops

for Loop

bash
# Loop over a list
for env in production staging development; do
    echo "Checking $env..."
done

# Loop over an array
servers=("web01" "web02" "db01")
for server in "${servers[@]}"; do
    ssh "$server" 'systemctl status nginx'
done

# C-style for loop
for (( i=0; i<10; i++ )); do
    echo "Iteration $i"
done

# Loop over files
for config in /etc/nginx/conf.d/*.conf; do
    nginx -t -c "$config" && echo "OK: $config"
done

# Loop over command output (safe with mapfile)
mapfile -t pods < <(kubectl get pods -o name)
for pod in "${pods[@]}"; do
    kubectl describe "$pod"
done

while and until

bash
# Retry loop with backoff
attempt=0
max_attempts=5
delay=2

until curl -sf http://localhost:8080/health; do
    (( attempt++ ))
    if (( attempt >= max_attempts )); then
        echo "Service failed to start after ${max_attempts} attempts" >&2
        exit 1
    fi
    echo "Attempt ${attempt}/${max_attempts} failed. Retrying in ${delay}s..."
    sleep "$delay"
    (( delay *= 2 ))   # exponential backoff
done

echo "Service is healthy"
bash
# Read a file line by line
while IFS= read -r line; do
    echo "Processing: $line"
done < /etc/hosts

# Process pipeline output line by line
docker ps --format '{{.Names}}' | while IFS= read -r container; do
    echo "Container: $container"
    docker stats --no-stream "$container"
done

Loop Control

bash
for i in {1..20}; do
    if (( i % 3 == 0 )); then
        continue   # skip multiples of 3
    fi
    if (( i > 15 )); then
        break      # stop at 15
    fi
    echo "$i"
done

Structuring Scripts with a main() Function

The pattern below separates definition from execution, makes the script sourceable without running, and ensures all variables are scoped:

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

# ---------- Constants ----------
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_DIR="/var/log/myapp"

# ---------- Functions ----------
log_info()  { echo "[INFO]  $(date '+%H:%M:%S') $*"; }
log_error() { echo "[ERROR] $(date '+%H:%M:%S') $*" >&2; }

usage() {
    echo "Usage: ${SCRIPT_NAME} <environment> [--dry-run]"
    exit 1
}

deploy() {
    local env="$1"
    local dry_run="${2:-false}"

    log_info "Starting deploy to ${env} (dry_run=${dry_run})"

    if [[ "$dry_run" == "true" ]]; then
        log_info "Dry run — no changes made"
        return 0
    fi

    # ... deployment logic ...
}

# ---------- Main ----------
main() {
    local environment="${1:-}"
    local dry_run="false"

    [[ -z "$environment" ]] && usage

    if [[ "${2:-}" == "--dry-run" ]]; then
        dry_run="true"
    fi

    deploy "$environment" "$dry_run"
}

main "$@"