You have written scripts that run end to end - now it is time to stop repeating yourself. This lesson shows you how to package logic into reusable functions, share code across scripts with sourceable libraries, and parse command-line arguments like a professional CLI tool. By the end you will have a personal Bash toolbox you can use in every automation task.

1. Learning Objectives

  • Define Bash functions using both the name() and function name syntax styles
  • Pass and consume positional arguments with $1, $@, $#, and shift
  • Return exit statuses with return and capture function output with command substitution
  • Control variable scope with local to keep functions isolated and predictable
  • Build sourceable library files and share functions across many scripts with source
  • Parse command-line options and flags with getopts and OPTARG
  • Assemble a complete, reusable CLI helper script from everything in this lesson

2. Why This Matters

Imagine maintaining ten deployment scripts, each with its own copy of logging, error handling, and retry logic. A bug in the retry function means editing ten files, and the next engineer who adds a script copy-pastes a slightly different version. This is how automation rots.

Real teams solve this the same way they solve code duplication in any language: they extract shared behavior into functions, group those functions into libraries, and source the library wherever it is needed. In this lesson you will learn exactly that, so your scripts become small, readable entry points that reuse one trusted toolbox instead of ten drifting copies.

3. Core Concepts

3.1 What Is a Bash Function?

A Bash function is a named block of commands that runs only when you call it. Functions accept arguments, set exit statuses, and can share or isolate variables. They are the primary tool for turning a long linear script into maintainable, testable pieces.

#!/usr/bin/env bash
# greet.sh - a function runs only when called

greet() {
    echo "Hello from a Bash function!"
}

greet
The simplest possible function

3.2 Two Syntax Styles

Bash accepts two equivalent definitions. The portable POSIX style name() { ... } is recommended; the function keyword form is Bash-specific but reads clearly. Never put a space between the name and (), and always put a space after the opening brace.

#!/usr/bin/env bash
# Alternate syntax - identical result

function greet {
    echo "Hello from a Bash function!"
}

greet
The function keyword form

3.3 Arguments and Return Values

Inside a function, $1, $2, ... refer to positional arguments; $@ is all arguments, $# is the count, and shift discards the first argument. A function reports success or failure through its exit status, and return N sets it explicitly (0-255). To hand back data rather than a status, print it and capture the output with command substitution.

3.4 Variable Scope

Every variable in Bash is global by default, even inside functions. Declaring local var inside a function creates a private copy that disappears when the function returns, which prevents accidental cross-talk. Use export only when a child process must inherit the variable.

3.5 Libraries and the source Command

A Bash library is simply a file of function definitions. source lib.sh (or the shorthand . lib.sh) executes the file in the current shell, so the functions become available immediately. Running the library with ./lib.sh would launch a subshell and its definitions would vanish when it exits - that is why libraries must be sourced, not executed.

3.6 Parsing Options with getopts

getopts is a Bash builtin that parses -a and -b value style options in a loop. Each iteration stores the current option letter in a variable, the option argument in $OPTARG, and after the loop shift $((OPTIND - 1)) removes the parsed options so any positional arguments remain.

4. Hands-On Practice

4.1 Defining Your First Function

Create greet.sh and run it with bash greet.sh:

#!/usr/bin/env bash
greet() {
    echo "Hello from a Bash function!"
}

greet
greet.sh
kubectl get pods -w
$ bash greet.sh
Hello from a Bash function!
Running the script calls the function

4.2 Passing Arguments to Functions

Arguments are positional inside the function. This function expects an app name and an environment:

#!/usr/bin/env bash
deploy() {
    echo "Deploying $1 to environment $2"
    echo "All arguments: $@"
    echo "Argument count: $#"
}

deploy app-prod production
Reading positional arguments
kubectl get pods -w
$ bash deploy.sh
Deploying app-prod to environment production
All arguments: app-prod production
Argument count: 2
Positional arguments in action

When the number of arguments is unknown, walk through them with shift:

#!/usr/bin/env bash
process() {
    while [ $# -gt 0 ]; do
        echo "Handling: $1"
        shift
    done
}

process one two three
Looping over all arguments
kubectl get pods -w
$ bash process.sh
Handling: one
Handling: two
Handling: three
shift consumes one argument per iteration

4.3 Returning Values and Exit Codes

Use return for a status check that can drive an if statement:

#!/usr/bin/env bash
is_dir() {
    [ -d "$1" ]
}

if is_dir /etc; then
    echo "/etc exists and is a directory"
fi
Function exit status drives if

Use command substitution when you need actual data back instead of a status:

#!/usr/bin/env bash
get_uptime() {
    uptime -p
}

server_uptime=$(get_uptime)
echo "Server has been up: $server_uptime"
Capturing function output
kubectl get pods -w
$ bash uptime.sh
Server has been up: up 14 days, 3 hours
Output captured into a variable

4.4 Local Variables and Scope

#!/usr/bin/env bash
count=0          # global

bump() {
    local count=5   # local to this function
    echo "Inside function: count=$count"
}

bump
echo "Outside function: count=$count"
local keeps the global intact
kubectl get pods -w
$ bash scope.sh
Inside function: count=5
Outside function: count=0
The global count is untouched

Without local, the assignment inside bump would overwrite the global count. Declaring local is what keeps helper functions from corrupting the state of the script that called them.

4.5 Building a Sourceable Library

Create lib.sh with the functions every script in your toolbox will use:

#!/usr/bin/env bash
# lib.sh - reusable toolbox functions

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

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

die() {
    log_error "$*"
    exit 1
}

retry() {
    local attempts=$1
    shift
    local n=0
    until "$@"; do
        n=$((n + 1))
        if [ "$n" -ge "$attempts" ]; then
            die "Command failed after $attempts attempts: $*"
        fi
        log_info "Retry $n of $attempts..."
        sleep 2
    done
}
lib.sh - logging, error handling, and retry

Now a script can reuse the library. Create backup.sh in the same directory:

#!/usr/bin/env bash
# backup.sh - uses the library
source "$(dirname "$0")/lib.sh"

log_info "Starting backup"
retry 3 rsync -av /data/ backups/
log_info "Backup finished"
backup.sh sources lib.sh
kubectl get pods -w
$ bash backup.sh
[INFO] 2026-08-04 09:12:33 Starting backup
[INFO] 2026-08-04 09:12:35 Backup finished
Library functions available in the script

4.6 Parsing Options with getopts

#!/usr/bin/env bash
usage() {
    echo "Usage: $0 [-v] [-n name]"
    exit 1
}

verbose=0
name="world"

while getopts "vn:" opt; do
    case "$opt" in
        v) verbose=1 ;;
        n) name="$OPTARG" ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

echo "Hello, $name"
[ "$verbose" -eq 1 ] && echo "Verbose mode on"
hello.sh parses -v and -n
kubectl get pods -w
$ bash hello.sh -v -n DevOps
Hello, DevOps
Verbose mode on
Flags and options working together

Run it several ways to see each option in action: bash hello.sh, bash hello.sh -n DevOps, and bash hello.sh -v -n DevOps.

4.7 Complete Project: A Deploy Helper Toolbox

Combine everything into a small CLI that wraps a build and deploy workflow:

#!/usr/bin/env bash
# deploy-helper.sh - a small CLI built from this lesson
source "$(dirname "$0")/lib.sh"

env="staging"
tag="latest"
verbose=0

usage() {
    echo "Usage: $0 [-e env] [-t tag] [-v]"
    exit 1
}

while getopts "e:t:v" opt; do
    case "$opt" in
        e) env="$OPTARG" ;;
        t) tag="$OPTARG" ;;
        v) verbose=1 ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

build() {
    log_info "Building myapp:$tag"
    docker build -t "myapp:$tag" .
}

deploy() {
    log_info "Deploying myapp:$tag to $env"
    docker run -d --name "myapp-$env" "myapp:$tag"
}

main() {
    build || die "Build failed"
    deploy || die "Deploy failed"
    [ "$verbose" -eq 1 ] && log_info "Deploy to $env complete"
}

main
deploy-helper.sh

Run it with bash deploy-helper.sh -e production -t 1.2.0 -v. The script now reads like an interface: options up top, small named functions in the middle, and a main function that reads like a checklist.

5. Common Errors & Solutions

  • command not found - calling a function before it is defined, or inside a subshell. Bash parses scripts top to bottom, so define functions before you call them and source libraries at the top of the script.
  • Return value wraps unexpectedly - return 300 silently becomes 44 because exit statuses only support 0-255. Use return 0 or return 1 for status and echo plus command substitution for data.
  • A variable changed everywhere after calling a function - a missing local declaration let the function write to a global variable. Declare local for everything a function uses internally.
  • Library functions unavailable in my script - the library was executed (./lib.sh) instead of sourced (source lib.sh or . lib.sh), so its definitions died with the subshell. Use source "$(dirname "$0")/lib.sh" to make the path robust.
  • getopts skips or misparses options - the option string does not match the flags, $OPTARG is read outside the loop, or OPTIND was not reset before a second parse pass. Set OPTIND=1 before parsing again.

6. Summary Checklist

  • I can define functions with both syntax styles and call them from a script
  • I pass arguments with $1, read them with $@ and $#, and walk them with shift
  • I use return for exit statuses and command substitution for data
  • I use local inside functions to prevent scope leakage
  • I build lib.sh libraries and load them with source
  • I parse flags and options with getopts, OPTARG, and OPTIND
  • I can assemble a reusable CLI helper script with a main function

7. Practice Exercise

Build backup-tool.sh that: defines check_deps, backup_dir, and rotate_backups functions; accepts -s SOURCE, -d DEST, and -k KEEP options via getopts; validates that the source directory exists and fails with a clear error otherwise; and keeps only the newest KEEP backups using ls -t and tail -n +. Try it against a real directory, then break it on purpose (missing source, missing option) and confirm your error messages are helpful.

8. Next Steps

You now have a toolbox: functions that take arguments, libraries that share code across scripts, and CLIs that parse options cleanly. The next lesson will cover Bash String Manipulation and Parameter Expansion - default values (${var:-default}), substring extraction, length checks, and pattern replacement - the text-wrangling superpowers that make your functions even more flexible when processing logs and configuration files.

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen August 4, 2026

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment