Building and Sourcing Reusable Bash Libraries
Organize shared helpers into sourceable .sh library files with include guards and namespaced function prefixes.
Building and Sourcing Reusable Bash Libraries is a free DevOps Bootcamp lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the DevOps Bootcamp learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
What Is a Bash Library?
In software engineering, a library is a collection of reusable functions that multiple programs can share. Bash supports the same concept through sourceable shell scripts.
Instead of copy-pasting helper functions into every script, you place them in a dedicated .sh file and load it with the source command (or its shorthand .). Any functions, variables, or aliases defined in that file become available in the calling script's current shell session.
- Promotes the DRY principle (Don't Repeat Yourself)
- Centralises bug fixes — fix once, all callers benefit
- Makes individual scripts shorter and easier to read
- Enables team-wide consistency in logging, error handling, and utility logic
A well-structured Bash project typically has a lib/ directory containing these shared files, mirroring the conventions of higher-level languages.
The source Command and the Dot Operator
There are two equivalent ways to load a library file into the current shell environment:
source /path/to/lib.sh— the explicit, readable form. /path/to/lib.sh— the POSIX-compatible shorthand
Both execute the file in the current shell process, not a subshell, so every function and variable defined inside it becomes part of your script's environment immediately after the call.
A common pattern is to locate the library relative to the calling script using $BASH_SOURCE, which makes the project portable regardless of where it is installed.
#!/usr/bin/env bash
# main.sh — load a library relative to this script's own location
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/utils.sh"
echo "Library loaded. Calling greet..."
greet "World"Creating Your First Library File
A library file is a plain .sh file that contains only function definitions (and occasionally constants). It should not run any side-effect code at the top level — it is meant to be sourced, not executed directly.
Key conventions:
- Start with a shebang comment describing the library's purpose
- Define functions only — no
mainlogic at the top level - Use
returninside functions (neverexit, which would kill the caller) - Keep the file in a
lib/subdirectory of your project
#!/usr/bin/env bash
# lib/utils.sh — General-purpose utility functions
# Print a greeting message
greet() {
local name="${1:-stranger}"
echo "Hello, ${name}!"
}
# Print a timestamped log line to stderr
log_info() {
echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}
# Print an error message and return a failure code
log_error() {
echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
return 1
}Include Guards: Preventing Double-Sourcing
When several scripts source the same library, or when a library sources another library that is also sourced by the main script, functions can be defined multiple times. This wastes time and can cause subtle bugs if a function's body is replaced mid-run.
The solution is an include guard — a variable that acts as a flag. On the first load the variable is unset, so the file proceeds. On every subsequent load the guard is already set, so the file returns immediately.
This is the Bash equivalent of #pragma once in C/C++ or if not already imported patterns in other languages.
#!/usr/bin/env bash
# lib/utils.sh — with include guard
# Guard: if already sourced, do nothing
[[ -n "${_LIB_UTILS_LOADED:-}" ]] && return 0
_LIB_UTILS_LOADED=1
greet() {
local name="${1:-stranger}"
echo "Hello, ${name}!"
}
log_info() {
echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}
log_error() {
echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
return 1
}Namespaced Function Prefixes
Bash has a single global namespace for functions. If two libraries each define a function called log or init, the second definition silently overwrites the first.
The standard defence is a namespace prefix: every function in a library is prefixed with the library's short name followed by two colons (::) or an underscore. For example, a string utilities library uses str::, a file library uses file::.
- Collisions become extremely unlikely
- The calling code is self-documenting —
str::trimtells you exactly where the function lives - Grep-ability improves:
grep 'str::' main.shinstantly shows all string-lib calls
#!/usr/bin/env bash
# lib/str.sh — String utility library (namespaced)
[[ -n "${_LIB_STR_LOADED:-}" ]] && return 0
_LIB_STR_LOADED=1
# Trim leading and trailing whitespace
str::trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
echo "$s"
}
# Convert string to uppercase
str::upper() {
echo "${1^^}"
}
# Convert string to lowercase
str::lower() {
echo "${1,,}"
}
# Check if a string contains a substring
str::contains() {
[[ "$1" == *"$2"* ]]
}Organising a lib/ Directory
As a project grows, a single utils.sh becomes unwieldy. Split responsibilities into focused library files inside a lib/ directory:
lib/log.sh— logging helpers (log::info,log::warn,log::error)lib/str.sh— string manipulation (str::trim,str::upper)lib/fs.sh— filesystem helpers (fs::require_dir,fs::safe_rm)lib/net.sh— network checks (net::wait_for_port,net::is_online)
A single bootstrap loader file (lib/bootstrap.sh) can source all of them in the correct order, so every script only needs one source call.
#!/usr/bin/env bash
# lib/bootstrap.sh — Load all project libraries in dependency order
[[ -n "${_LIB_BOOTSTRAP_LOADED:-}" ]] && return 0
_LIB_BOOTSTRAP_LOADED=1
_BOOTSTRAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${_BOOTSTRAP_DIR}/log.sh"
source "${_BOOTSTRAP_DIR}/str.sh"
source "${_BOOTSTRAP_DIR}/fs.sh"
source "${_BOOTSTRAP_DIR}/net.sh"
log::info "All libraries loaded."A Complete Logging Library
Logging is the most commonly shared concern across scripts. A dedicated lib/log.sh centralises output formatting, log levels, and colour codes.
With a library like this in place, every script in the project prints consistent, timestamped, colour-coded messages without duplicating any formatting code.
#!/usr/bin/env bash
# lib/log.sh — Coloured, levelled logging library
[[ -n "${_LIB_LOG_LOADED:-}" ]] && return 0
_LIB_LOG_LOADED=1
# Colour codes (disabled when not writing to a terminal)
_LOG_RED=''; _LOG_YEL=''; _LOG_GRN=''; _LOG_RST=''
if [[ -t 2 ]]; then
_LOG_RED='\033[0;31m'
_LOG_YEL='\033[0;33m'
_LOG_GRN='\033[0;32m'
_LOG_RST='\033[0m'
fi
_log::_print() {
local level="$1" colour="$2"; shift 2
printf "%b[%s]%b %s %s\n" \
"$colour" "$level" "$_LOG_RST" \
"$(date '+%H:%M:%S')" "$*" >&2
}
log::info() { _log::_print 'INFO ' "$_LOG_GRN" "$@"; }
log::warn() { _log::_print 'WARN ' "$_LOG_YEL" "$@"; }
log::error() { _log::_print 'ERROR' "$_LOG_RED" "$@"; return 1; }
log::fatal() { _log::_print 'FATAL' "$_LOG_RED" "$@"; exit 1; }A Filesystem Helpers Library
Scripts that manipulate files and directories often repeat the same defensive checks: does this directory exist? Is this path writeable? Am I about to delete something important?
Centralising these checks in lib/fs.sh makes every consumer script safer and more readable. Note how each function uses return 1 on failure rather than exit, preserving the caller's ability to handle the error gracefully.
#!/usr/bin/env bash
# lib/fs.sh — Filesystem helper library
[[ -n "${_LIB_FS_LOADED:-}" ]] && return 0
_LIB_FS_LOADED=1
# Ensure a directory exists; create it if not
fs::require_dir() {
local dir="$1"
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir" || { echo "[fs] Cannot create directory: $dir" >&2; return 1; }
fi
}
# Remove a file only if it exists (no error on missing)
fs::safe_rm() {
local target="$1"
[[ -e "$target" ]] && rm -rf -- "$target"
return 0
}
# Assert that a file exists and is readable
fs::require_file() {
local file="$1"
[[ -f "$file" && -r "$file" ]] || {
echo "[fs] Required file missing or unreadable: $file" >&2
return 1
}
}Versioning Your Library with a Constant
When your libraries are shared across multiple projects or distributed to a team, it becomes important to know which version of a library is loaded at runtime. A simple convention is to export a version constant from each library.
Callers can then assert a minimum version at startup, catching mismatches early rather than debugging mysterious failures later. The guard variable doubles as the version string, combining two responsibilities in one variable.
#!/usr/bin/env bash
# lib/str.sh — versioned example
# Guard doubles as the version identifier
[[ -n "${_LIB_STR_LOADED:-}" ]] && return 0
readonly _LIB_STR_LOADED='1.3.0'
# Caller can validate the version
str::version() { echo "$_LIB_STR_LOADED"; }
# ---- Utility functions ----
str::trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
echo "$s"
}
str::repeat() {
local str="$1" count="$2" result=''
for (( i=0; i<count; i++ )); do result+="$str"; done
echo "$result"
}A Self-Contained Demo: Using Multiple Libraries
This scene shows a realistic script that sources two libraries and uses functions from each. Notice how the main script stays clean — it expresses intent, while all implementation details live in the libraries.
The script is runnable as a standalone file because it defines the libraries inline using here-documents written to temp files. In a real project each library would live in its own file under lib/.
#!/usr/bin/env bash
# Standalone demo: inline libs written to /tmp, then sourced
set -euo pipefail
# --- Create a temporary lib/log.sh ---
TMPDIR_LIBS="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_LIBS"' EXIT
cat > "${TMPDIR_LIBS}/log.sh" <<'LIBEOF'
[[ -n "${_LIB_LOG_LOADED:-}" ]] && return 0
_LIB_LOG_LOADED=1
log::info() { echo "[INFO] $*"; }
log::error() { echo "[ERROR] $*" >&2; return 1; }
LIBEOF
cat > "${TMPDIR_LIBS}/str.sh" <<'LIBEOF'
[[ -n "${_LIB_STR_LOADED:-}" ]] && return 0
_LIB_STR_LOADED=1
str::upper() { echo "${1^^}"; }
str::trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}";
s="${s%"${s##*[![:space:]]}"}" ; echo "$s"; }
LIBEOF
# --- Source both libraries ---
source "${TMPDIR_LIBS}/log.sh"
source "${TMPDIR_LIBS}/str.sh"
# --- Main logic ---
log::info "Libraries loaded successfully."
raw_input=" hello from bash libraries "
trimmed="$(str::trim "$raw_input")"
log::info "Trimmed: '${trimmed}'"
log::info "Uppercased: '$(str::upper "$trimmed")'"Best Practices and Common Pitfalls
Before shipping a library for team use, run through this checklist:
- Include guard — every library must have one; document the guard variable name at the top
- No top-level side effects — never
cd,echo, or modify global state outside a function body - Use
localfor all variables inside functions — withoutlocal, every assignment bleeds into the caller's scope - Return, never exit —
exitinside a sourced file terminates the entire calling shell - Validate inputs — check required arguments and return a meaningful error code when they are missing
- Document with comments — describe what each function does, its parameters, and its return value
- Avoid
set -einside library files — callers may have their own error-handling strategy; let them decide
Knowledge Check: Include Guards
Consider a project where main.sh sources both lib/bootstrap.sh and lib/log.sh, and lib/bootstrap.sh also sources lib/log.sh internally. What is the primary purpose of the include guard in lib/log.sh?
Lesson Recap: Bash Libraries Done Right
You have covered all the essential techniques for building and consuming reusable Bash libraries. Here is what to take away:
- Source with
sourceor.— loads a file into the current shell, making its functions immediately available - Use
$BASH_SOURCEto resolve library paths relative to the calling script, keeping projects portable - Include guards (
[[ -n "${_GUARD:-}" ]] && return 0) prevent duplicate definitions when multiple files source the same library - Namespace prefixes (
log::,str::,fs::) eliminate function name collisions across libraries - A
lib/directory with focused, single-responsibility files keeps large projects maintainable - A bootstrap loader (
lib/bootstrap.sh) gives every script a single source call to load the whole ecosystem - Never use
exitor top-level side effects in library files — only function definitions and constants belong there
Apply these patterns consistently and your shell scripts will be as modular and maintainable as code written in any higher-level language.
Frequently asked questions
Is the “Building and Sourcing Reusable Bash Libraries” lesson free?
Yes — the full text of “Building and Sourcing Reusable Bash Libraries” is free to read here on the web, and the DevOps Bootcamp course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the DevOps Bootcamp course, upgrade to CoddyKit PRO.
What will I learn in “Building and Sourcing Reusable Bash Libraries”?
Organize shared helpers into sourceable .sh library files with include guards and namespaced function prefixes. You practise DevOps Bootcamp with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start DevOps Bootcamp?
No prior experience is required. DevOps Bootcamp on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Building and Sourcing Reusable Bash Libraries” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this DevOps Bootcamp lesson?
Yes. Every DevOps Bootcamp lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Designing Functions with Local Scope and Return Codes
- Building and Sourcing Reusable Bash Libraries
- Parsing Flags and Arguments with getopts
- Passing Arrays and Associative Maps Between Functions