Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Bash Call-Stack Metadata: Resolve Script Paths and Report Failures

Use BASH_SOURCE, FUNCNAME, and BASH_LINENO to locate Bash code and explain nested calls without confusing source paths with argv or $0.

Bash exposes arrays that describe the files and functions involved in the current call stack. BASH_SOURCE, FUNCNAME, and BASH_LINENO can make a diagnostic identify where a function was defined and which source line called it. They also help a script locate files next to itself when it is launched from another working directory. These values are related, but they are not interchangeable with $0, the current directory, or a canonical path to the executable.

The most common mistake is to treat $0 as “the file containing this code.” For a shell invoked with a script, $0 usually identifies the script’s invocation name. But when a file is sourced, the code runs in the caller’s shell; $0 continues to describe that shell or top-level script. BASH_SOURCE records source-file names for the current execution context, including files loaded with source or ..

Choose the path variable that answers the question

Use $0 when the question is “what invocation name did the process receive?” Use ${BASH_SOURCE[0]} when the question is “which source file is currently executing at the top of this stack?” Inside a function, BASH_SOURCE[0] is the source file containing that function’s current frame. When one script sources a helper which calls another function, the array preserves the nested file context that a single scalar $0 cannot represent.

report_origin() {
    printf 'invocation=$0: %s\n' "$0"
    printf 'current source: %s\n' "${BASH_SOURCE[0]}"
    printf 'caller source:  %s\n' "${BASH_SOURCE[1]:-<top-level>}"
}

The caller index is useful only when that frame exists. Use a default for optional stack entries. A script run from an interactive shell, a function loaded from a library, and a sourced file have different stack shapes; do not assume the caller always comes from another file. Capture the value inside the function that needs it, because the stack arrays describe the current execution and may change as functions return.

For locating a sibling data or library directory, resolve the directory name relative to the source path rather than the process working directory:

script_dir=$(
    cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P
) || exit 1
config_file=$script_dir/config/default.conf

This example uses dirname and pwd to derive a physical directory path. It handles ordinary relative invocation paths and spaces because the expansions are quoted. It does not resolve every symbolic-link chain back to the original link target; the directory containing the path by which the script was invoked may be the intended location. If symlink canonicalization is a requirement, define that policy explicitly and use a platform-appropriate resolver.

Do not derive sibling paths from pwd unless the contract says the user must launch from a specific directory. A caller can run /opt/app/bin/check while standing in /tmp; a ./config lookup then reads the wrong place or fails. Conversely, some commands intentionally operate on the caller’s working directory. Keep “script resources relative to source” and “user data relative to current directory” as separate choices.

Read the stack arrays as aligned diagnostics

FUNCNAME contains the names of shell functions in the current call stack. BASH_LINENO contains call-site line numbers, and BASH_SOURCE supplies the associated source files. Bash documents their indexing relationship: a BASH_LINENO entry records the source-file line at which the corresponding FUNCNAME entry was invoked, with the caller file found at the next BASH_SOURCE index. This is why adding one to an index can be correct in a stack trace and wrong in a script-path lookup.

print_call_stack() {
    local i name source line
    for ((i = 0; i < ${#FUNCNAME[@]}; i++)); do
        name=${FUNCNAME[i]:-MAIN}
        source=${BASH_SOURCE[i + 1]:-${BASH_SOURCE[i]:-<unknown>}}
        line=${BASH_LINENO[i]:-?}
        printf '#%d %s called at %s:%s\n' "$i" "$name" "$source" "$line" >&2
    done
}

The formatter sends the trace to stderr so it does not contaminate a function’s standard output. Treat it as diagnostic context rather than a guaranteed source-map format. Test the actual frame ordering in the Bash versions you support, particularly when source, command substitutions, traps, or subshells add execution layers. A simpler one-frame message is often better than a misleading trace that was copied from a different call shape.

The arrays are shell-provided metadata, not normal application state. Assigning to BASH_LINENO does not rewrite the call history. The names and line values may be less meaningful at an interactive prompt or after code has returned. Do not serialize them as a stable API or parse them back into control flow. Use them to explain a failure to a human or to attach source location to a log record.

Make errors identify their origin

A library can use a small helper to report the current function and source location while preserving the original operation’s failure status. The caller should decide whether the library logs, returns a status, or raises a shell error; do not bake in exit if the file may be sourced into an existing shell.

die_at_callsite() {
    local message=$1
    local function=${FUNCNAME[1]:-MAIN}
    local source=${BASH_SOURCE[1]:-${BASH_SOURCE[0]:-<unknown>}}
    local line=${BASH_LINENO[0]:-?}
    printf '%s: %s: %s:%s: %s\n' \
        "${0##*/}" "$function" "$source" "$line" "$message" >&2
    return 1
}

load_configuration() {
    local file=$1
    [[ -r $file ]] || die_at_callsite "configuration is not readable: $file"
}

Because die_at_callsite is itself another function frame, it deliberately reads index 1 for its caller. If you call it from a trap or add a wrapper function, the expected frame changes. Keep the lookup and formatting logic in one helper, and write a test that asserts a known function and file appear. Avoid including raw secrets or untrusted values in logs; quote or sanitize user-controlled content.

When shell options such as errtrace or functrace change how traps are inherited, the trap’s frame stack can also differ. A stack trace helper does not make an ERR trap an exception system. Treat a nonzero status as the primary signal, capture it before running another command, and use the source arrays as supporting evidence. Existing failure-handling logic should remain responsible for cleanup and return status.

Keep source paths and executable paths distinct

BASH_SOURCE records the name used to read source code; it is not guaranteed to be an absolute path or a symlink-resolved executable path. A relative path stays relative until your code resolves it. The process may also have been invoked with a different spelling from the one returned by a path search. If a diagnostic requires the exact interpreter binary, inspect process information using platform tools rather than inferring it from the script’s source filename.

For modular applications, a common layout is a top-level entry point that locates its own directory and sources one or more sibling libraries. Do this once, near the entry point, and pass absolute paths into lower-level functions. That keeps helpers testable and avoids each library independently guessing where the project root lives.

app_root=$(
    cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P
) || exit 1

source "$app_root/lib/logging.sh"
source "$app_root/lib/configuration.sh"

The explicit path computation makes source resolution independent of the current directory. It does not make the project tree trustworthy; validate permissions and ownership if the code runs with elevated privileges. Never source a path derived from untrusted environment input without checking that it resolves to an expected, controlled directory.

Validate in each execution mode

Test a file when it is run directly, sourced from another script, loaded from a function library, and invoked through a relative path from a different working directory. Add a symlink case if your installation uses symbolic links. Compare $0, BASH_SOURCE, and the relevant stack arrays so assumptions are visible. Verify that diagnostics go to stderr and do not alter the function’s normal stdout contract.

Use the lowest Bash version supported by the application for the compatibility run. These special arrays are Bash features, so a script that relies on them should declare Bash as its interpreter and should not silently run under sh. bash -n checks syntax, but it cannot prove that a path resolution works after source, that a line number points to the intended frame, or that a path with spaces remains intact. Test those behaviors with a temporary fixture tree.

For a multi-file test, create a tiny entry point that sources a helper, has the helper call a named function, and triggers a known failure path. Assert the reported file and line against the fixture rather than comparing a full trace string whose spacing may change. Run it once by absolute path and once by a relative path from a different directory. Keep stdout reserved for the function’s documented data and direct stack details to stderr, so a pipeline consumer does not have to parse diagnostics from normal output.

Path discovery deserves its own test too. A valid path can still point at the wrong configuration if the script was invoked through a symlink or installed into a different directory than expected. Decide whether the application should follow the symlink or use the path by which the code was loaded, then test that chosen rule. If data is installed separately from code, pass its location in as a configuration value instead of silently deriving it from BASH_SOURCE.

Stack metadata is most valuable when paired with simple APIs: functions accept paths as arguments, return statuses instead of terminating the shell, and send diagnostics to stderr. Use BASH_SOURCE for code provenance, BASH_LINENO and FUNCNAME for call context, and $0 only for the invocation identity it actually represents. Keeping those roles separate makes Bash tooling easier to debug without turning internal shell variables into a fragile framework.

Related:

Sources:

Comments