Functions and Error Handling#

Concepts#

Functions#

Functions let you group commands under a name and reuse them. They reduce repetition and make scripts easier to read and maintain.

Defining Functions#

# Syntax 1 (preferred)
function_name() {
    commands
}

# Syntax 2
function function_name {
    commands
}

Calling Functions#

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

greet          # call the function
greet          # call it again

Function Arguments#

Functions receive arguments the same way scripts do — $1, $2, $@, $#:

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

greet "Alice"    # Hello, Alice!
greet "Bob"      # Hello, Bob!
add() {
    echo $(( $1 + $2 ))
}

result=$(add 5 3)
echo "5 + 3 = $result"   # 5 + 3 = 8

Return Values#

Functions communicate results in two ways:

1. Exit statusreturn N (0-255, like exit codes):

is_root() {
    if [[ $(id -u) -eq 0 ]]; then
        return 0    # success/true
    else
        return 1    # failure/false
    fi
}

if is_root; then
    echo "Running as root"
else
    echo "Not root"
fi

2. Outputecho the result and capture with $():

get_hostname() {
    hostname
}

name=$(get_hostname)
echo "This machine is: $name"

return is for pass/fail status. echo/printf is for returning data.

Local Variables#

By default, variables in functions are global — they modify the same variables as the rest of the script. Use local to create function-scoped variables:

demo() {
    local x=10       # only exists inside this function
    global_y=20      # modifies the global variable
    echo "Inside: x=$x"
}

x=5
demo
echo "Outside: x=$x"    # Still 5 (local didn't change it)
echo "Outside: y=$global_y"  # 20 (global was set)

Best practice: Always use local for variables that should not escape the function.

Error Handling#

The Problem#

By default, if a command fails, the script continues to the next command. This can cause cascading problems:

#!/bin/bash
cd /nonexistent/directory    # fails silently
rm -rf *                     # runs in the WRONG directory!

set -e — Exit on Error#

#!/bin/bash
set -e

cd /nonexistent/directory    # fails → script exits immediately
rm -rf *                     # never runs

set -e makes the script exit immediately when any command returns a non-zero exit code.

Caveats: Commands in if conditions, pipes, and || chains do not trigger set -e:

set -e
if ! command_that_fails; then    # OK — failure is expected
    echo "It failed"
fi
command_that_fails || true       # OK — failure is caught

set -u — Error on Undefined Variables#

#!/bin/bash
set -u

echo "$undefined_variable"    # ERROR! Script exits.
# Without set -u, this would silently expand to empty string.

set -o pipefail — Detect Pipe Failures#

By default, a pipeline’s exit status is the exit status of the last command:

false | true
echo $?    # 0 (true succeeded, even though false failed!)

With pipefail, the pipeline fails if any command fails:

set -o pipefail
false | true
echo $?    # 1 (false failed)

The Standard Safety Header#

Most production scripts start with:

#!/bin/bash
set -euo pipefail

This combination:

  • -e — exit on any error
  • -u — error on undefined variables
  • -o pipefail — catch failures in pipes

trap — Run Cleanup on Exit#

trap executes a command when the script receives a signal or exits:

#!/bin/bash
set -euo pipefail

TEMPFILE=$(mktemp)

# Clean up temp file when script exits (for any reason)
trap "rm -f $TEMPFILE" EXIT

echo "Working with $TEMPFILE"
echo "data" > "$TEMPFILE"
# ... do work ...

# Whether the script succeeds or fails, the trap runs and cleans up

Common trap patterns:

# Clean up on exit
trap 'rm -f "$tmpfile"' EXIT

# Clean up on error (INT = Ctrl+C, TERM = kill)
trap 'echo "Interrupted!"; cleanup; exit 1' INT TERM

# Print the failing line number on error
trap 'echo "Error on line $LINENO"' ERR

Handling Errors Manually#

# Pattern: command || handle_error
mkdir /some/dir || { echo "Failed to create directory"; exit 1; }

# Pattern: check and exit
if ! cp source.txt dest.txt; then
    echo "Copy failed" >&2
    exit 1
fi

# Pattern: error function
die() {
    echo "ERROR: $1" >&2
    exit "${2:-1}"
}

[[ -f "$config" ]] || die "Config file not found: $config"

Note: >&2 redirects output to stderr — error messages should go to stderr, not stdout, so they are visible even when stdout is redirected.

Putting It Together#

#!/bin/bash
set -euo pipefail

# --- Error handling ---
die() {
    echo "ERROR: $1" >&2
    exit "${2:-1}"
}

cleanup() {
    rm -f "$TMPFILE"
}
trap cleanup EXIT

# --- Functions ---
check_root() {
    [[ $(id -u) -eq 0 ]] || die "This script must be run as root"
}

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

# --- Main ---
TMPFILE=$(mktemp)

check_root
log "Script started"
log "Processing data..."
# ... do work ...
log "Script completed successfully"

Lab#

Exercise 1: Basic Functions#

cd ~/lab/scripts

cat > functions.sh << 'SCRIPT'
#!/bin/bash

# A greeting function
greet() {
    local name="$1"
    local time_of_day

    hour=$(date +%H)
    if (( hour < 12 )); then
        time_of_day="morning"
    elif (( hour < 18 )); then
        time_of_day="afternoon"
    else
        time_of_day="evening"
    fi

    echo "Good $time_of_day, $name!"
}

# A function that returns data
disk_usage() {
    df / | tail -1 | awk '{print $5}' | tr -d '%'
}

# A function that returns status
is_installed() {
    dpkg -l "$1" &>/dev/null
}

# --- Use the functions ---
greet "${1:-User}"

usage=$(disk_usage)
echo "Disk usage: ${usage}%"

for pkg in bash python3 nonexistent-package; do
    if is_installed "$pkg"; then
        echo "$pkg: installed"
    else
        echo "$pkg: not installed"
    fi
done
SCRIPT

chmod +x functions.sh
./functions.sh
./functions.sh Alice

Exercise 2: Local vs Global Variables#

cd ~/lab/scripts

cat > scope.sh << 'SCRIPT'
#!/bin/bash

x="global"

change_x() {
    local x="local"
    echo "Inside function: x = $x"
}

leak_y() {
    y="leaked"    # no local — this is global!
    echo "Inside function: y = $y"
}

echo "Before: x = $x"
change_x
echo "After change_x: x = $x"    # still "global"

echo ""
echo "Before: y = ${y:-unset}"
leak_y
echo "After leak_y: y = $y"      # "leaked" — it escaped!
SCRIPT

chmod +x scope.sh
./scope.sh

Exercise 3: Error Handling with set#

cd ~/lab/scripts

cat > safescript.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

echo "This script uses the safety header."
echo "It will exit on any error."

# This would fail and stop the script:
# ls /nonexistent

# Undefined variable would fail:
# echo "$undefined"

# Handle expected errors:
if ls /nonexistent 2>/dev/null; then
    echo "Found it"
else
    echo "Not found (but we handled it)"
fi

echo "Script completed successfully."
SCRIPT

chmod +x safescript.sh
./safescript.sh

Exercise 4: trap for Cleanup#

cd ~/lab/scripts

cat > traptest.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

TMPFILE=$(mktemp)
trap 'echo "Cleaning up $TMPFILE"; rm -f "$TMPFILE"' EXIT

echo "Created temp file: $TMPFILE"
echo "some data" > "$TMPFILE"

echo "Temp file contains: $(cat "$TMPFILE")"
echo "Script ending normally..."
# The trap runs here — cleanup happens automatically
SCRIPT

chmod +x traptest.sh
./traptest.sh
# Notice "Cleaning up..." appears at the end

Exercise 5: A Robust Script#

cd ~/lab/scripts

cat > robust.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

die() { echo "ERROR: $1" >&2; exit "${2:-1}"; }
log() { echo "[$(date +%H:%M:%S)] $1"; }

# Check arguments
[[ $# -ge 1 ]] || die "Usage: $0 <directory>"

DIR="$1"

# Validate input
[[ -d "$DIR" ]] || die "Not a directory: $DIR"
[[ -r "$DIR" ]] || die "Cannot read: $DIR"

# Do work
log "Analyzing directory: $DIR"

file_count=$(find "$DIR" -maxdepth 1 -type f | wc -l)
dir_count=$(find "$DIR" -maxdepth 1 -type d | wc -l)
((dir_count--))  # subtract the directory itself

total_size=$(du -sh "$DIR" 2>/dev/null | cut -f1)

log "Results:"
echo "  Files:       $file_count"
echo "  Directories: $dir_count"
echo "  Total size:  $total_size"

log "Done."
SCRIPT

chmod +x robust.sh
./robust.sh /etc
./robust.sh /nonexistent 2>/dev/null; echo "Exit code: $?"

Review#

1. How do you define and call a function in Bash?

Define: function_name() { commands; }. Call: function_name arg1 arg2. Arguments are accessed as $1, $2, etc., just like script arguments.

2. What is the difference between `return` and `echo` in a function?

return N sets the function’s exit status (0-255) — used for pass/fail. echo prints output that can be captured with $() — used for returning data. Use return for status, echo for values.

3. Why should you use `local` for variables inside functions?

Without local, variables are global — they modify and can conflict with variables in the rest of the script. local creates a variable scoped to the function, preventing unintended side effects.

4. What does `set -euo pipefail` do?

-e: exit on any command failure. -u: treat undefined variables as errors. -o pipefail: a pipeline fails if any command in it fails (not just the last one). This combination catches most common scripting errors.

5. What is `trap` used for?

trap registers a command to run when the script exits or receives a signal. It is commonly used for cleanup (deleting temp files, releasing locks) to ensure cleanup happens even if the script fails.

6. Why should error messages be sent to stderr (`>&2`)?

So they are visible even when stdout is redirected to a file or pipe. If errors go to stdout, they get mixed into the data stream and may go unnoticed.


Previous: Loops | Next: Practical Scripts