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()andfunction namesyntax styles - Pass and consume positional arguments with
$1,$@,$#, andshift - Return exit statuses with
returnand capture function output with command substitution - Control variable scope with
localto keep functions isolated and predictable - Build sourceable library files and share functions across many scripts with
source - Parse command-line options and flags with
getoptsandOPTARG - 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
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
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
$ bash greet.sh
Hello from a Bash 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
$ bash deploy.sh
Deploying app-prod to environment production
All arguments: app-prod production
Argument count: 2
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
$ bash process.sh
Handling: one
Handling: two
Handling: three
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
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"
$ bash uptime.sh
Server has been up: up 14 days, 3 hours
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"
$ bash scope.sh
Inside function: count=5
Outside function: count=0
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
}
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"
$ bash backup.sh
[INFO] 2026-08-04 09:12:33 Starting backup
[INFO] 2026-08-04 09:12:35 Backup finished
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"
$ bash hello.sh -v -n DevOps
Hello, DevOps
Verbose mode on
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
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 300silently becomes44because exit statuses only support 0-255. Usereturn 0orreturn 1for status andechoplus command substitution for data. - A variable changed everywhere after calling a function - a missing
localdeclaration let the function write to a global variable. Declarelocalfor everything a function uses internally. - Library functions unavailable in my script - the library was executed (
./lib.sh) instead of sourced (source lib.shor. lib.sh), so its definitions died with the subshell. Usesource "$(dirname "$0")/lib.sh"to make the path robust. - getopts skips or misparses options - the option string does not match the flags,
$OPTARGis read outside the loop, orOPTINDwas not reset before a second parse pass. SetOPTIND=1before 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 withshift - I use
returnfor exit statuses and command substitution for data - I use
localinside functions to prevent scope leakage - I build
lib.shlibraries and load them withsource - I parse flags and options with
getopts,OPTARG, andOPTIND - I can assemble a reusable CLI helper script with a
mainfunction
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.
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment