Skip to content
Shell & TerminalHow-To Published Updated 4 min readViews unavailable

How to Build a Reusable Shell Function Library

A maintainable shell library needs a documented target shell, namespaced functions, no import-time surprises, and tests run in fresh processes.

Sourcing a file executes it in the caller’s current shell. That makes shell libraries convenient, but it also means an accidental exit, option change, directory change, or variable collision can corrupt the caller.

State the supported shell

A POSIX sh library cannot use Bash arrays or [[ ... ]]. A Bash library should say so and be sourced only from Bash scripts. Do not put a shebang on a library and assume it enforces anything when sourced; the current interpreter remains in control.

Use a project prefix to reduce collisions:

dc_log_info() {
  printf '%s\n' "INFO: $*" >&2
}

dc_require_command() {
  command -v "$1" >/dev/null 2>&1 || {
    dc_log_info "missing command: $1"
    return 127
  }
}

Library functions should return; they should not exit the entire caller unless that is the explicitly documented contract. Keep variables local where the target shell supports it, or namespace global scratch variables and restore them carefully in portable code.

Make sourcing quiet and repeatable

Import-time code should define functions and constants, not print banners, start processes, run network calls, or change directories. An optional load guard can prevent duplicate initialization, but it should not hide a library-version conflict.

Pass values as arguments instead of reading ambient globals. Send functional output to standard output and diagnostics to standard error so callers can safely use command substitution. Document whether functions accept arbitrary filenames, read standard input, or mutate files.

Test the integration boundary

Launch a fresh supported shell, source the library, invoke functions with spaces, glob characters, empty values, and expected failures, then check status and output. Run ShellCheck with the source relationship visible so it can analyze referenced definitions.

Version shared libraries with the scripts that consume them. Installing one mutable library into a global path can silently change many jobs at once; for critical automation, a vendored or packaged version gives reviewable upgrades and rollback.

Deciding whether a function needs to survive into a subshell

By default, a shell function defined in your current process isn’t automatically visible to a child process that shell spawns — a script launched via bash script.sh from within a session where you’ve defined library functions won’t see them unless you explicitly export them:

export -f dc_log_info

export -f is Bash-specific (POSIX sh has no equivalent mechanism at all), and it works by serializing the function’s definition into an environment variable the child process’s Bash then re-parses — a real, if somewhat unusual, implementation detail worth knowing about if a function mysteriously “disappears” the moment a script calls out to another script rather than sourcing a library directly inline.

Distinguishing a library bug from a caller misuse

When a sourced library function behaves unexpectedly, isolate which side actually owns the bug: call the function directly, with known-good arguments, in a fresh shell with nothing else sourced, and compare that result against the failing case. A library function reproducing incorrect behavior in complete isolation is a genuine library bug; a function working correctly in isolation but failing in the caller’s actual script usually points to environment state, a variable name collision, or an argument the caller is passing incorrectly — two categories that call for very different fixes.

Guarding against a library being sourced more than once

If your library might reasonably be sourced multiple times (directly and also transitively through another library that sources it), an explicit load guard prevents redefinition and any associated side effects from running twice:

if [ -n "${DC_LOG_LOADED:-}" ]; then
  return 0
fi
DC_LOG_LOADED=1

This is a genuinely different concern from the version-conflict issue mentioned earlier — a load guard just prevents the same file from re-executing its own definitions redundantly, while a version conflict is two different versions of a library both trying to load, which a simple guard variable won’t catch or resolve on its own.

Documenting the library’s own public interface explicitly

As a library grows, distinguish clearly (through a naming convention, a comment block, or a separate “internal” file) between functions meant for callers to use directly and internal helper functions that exist only to support the library’s own implementation. Callers reaching into internal helpers directly, because nothing marked them as off-limits, is a common way a library’s maintainer loses the freedom to refactor its internals later without breaking someone else’s script that depended on an implementation detail never meant to be part of the stable interface.

Sourcing with the portable dot, not just Bash’s source

. ./dc_log.sh

source is a Bash and Zsh convenience name; the POSIX-specified way to load a library file into the current shell is the single dot command, supported by every POSIX-compliant shell including Dash. A library meant to be usable from a #!/bin/sh script should be documented and tested against ., not source, since a script honoring its own POSIX shebang may be running under a shell where source doesn’t exist at all. Both forms otherwise behave identically: executing the named file’s contents in the current shell rather than a subshell, which is precisely the property that makes sourcing suitable for a function library in the first place.

Related:

Sources:

Comments