A shell function groups commands under a name and receives its own positional arguments. In an ordinary call, its body runs in the current shell rather than automatically starting a separate process. That makes functions useful for reusable operations, but it also means assignments, directory changes, and other shell state can affect the caller. A function needs an explicit contract for inputs, output, status, and any intentional side effects.

Arguments belong to each invocation

Calling demo_func first second makes those two values available as $1 and $2 inside that invocation, with $# equal to two. Another function call temporarily installs its own list. When it returns, the caller’s list is restored. Using shift within the callee therefore does not consume the caller’s positional arguments. In Bash, $0 does not change to the function name.

Forward the list with "$@" so empty arguments, spaces, and wildcard characters retain their boundaries. Validate the argument count before accessing required positions, and distinguish an omitted argument from a deliberately empty value. A function has no parameter declaration that automatically enforces those rules.

Local variables have dynamic scope

Bash’s local builtin creates a variable that shadows a name in an outer scope. When the function returns, that previous binding becomes visible again. Declare temporary values and loop counters locally when modifying the caller’s variable is not part of the interface. Portable shell code must not assume every interpreter implements Bash’s local-variable facilities.

However, Bash uses dynamic scope: a function called from another function can see the caller’s local variables. If it assigns to such a name without declaring its own local, it can modify the caller’s local binding. The outer global remains protected, but the calling function’s value changes. Local variables are not lexically private objects inaccessible to all other functions.

Local declarations also do not automatically restore the working directory, shell options, traps, or files modified by the body. Those effects need their own contract. Running a function inside ordinary command substitution instead gives it a subshell environment: its variable and directory changes do not propagate back, although file writes still can. Capturing output is not a general sandbox.

Status and data use different channels

A function’s status is the last executed command’s status unless an explicit return changes it. Use standard output for the documented payload and standard error for diagnostics. return 7 reports a status to the caller; it does not return the text seven as data. exit terminates the executing shell rather than merely returning from the function.

Separate a local declaration from a command-substitution assignment when the producer’s status matters. In local result=$(producer), the observed status belongs to the declaration builtin and can hide the producer’s failure. A subsequent assignment without the declaration has the substitution’s status. Capture that result before logging or cleanup changes it.

When it breaks

An unlocalized counter can corrupt a calling loop, including one using a local variable of its own. Tests should call helpers from another function as well as from top-level code. A global-only test misses the dynamic-scope interaction. An unexpected progress message on standard output similarly corrupts callers that capture a single promised value.

Do not rely on set -e to make a function fail predictably at every failed command. Its effect depends on calling context; a function used as an if condition can continue after failures inside its body. Explicitly check operations whose failure must stop subsequent work, and propagate the required status after any diagnostics.

Check it quickly

Run this complete block in Bash. It changes only variables and positional arguments inside a fresh shell. The worker intentionally modifies its caller’s local variable, while the second function demonstrates argument preservation and a nonzero return. The final pair contrasts combined declaration with separate assignment.

BASH_ENV= bash --noprofile --norc <<'BASH'
demo_value=global
demo_worker() { demo_value=changed; }
demo_scope() {
  local demo_value=inner
  printf 'local-before=%s\n' "$demo_value"
  demo_worker
  printf 'local-after=%s\n' "$demo_value"
}
demo_scope
printf 'global-after=%s\n' "$demo_value"
demo_args() {
  local demo_arg
  printf 'function-count=%s\n' "$#"
  for demo_arg in "$@"; do printf 'argument=<%s>\n' "$demo_arg"; done
  shift
  printf 'after-shift=%s\n' "$#"
  return 7
}
set -- outer-one outer-two
if demo_args '' 'two words'; then
  exit 99
else
  demo_status=$?
fi
printf 'function-status=%s outer-count=%s outer-first=%s\n' "$demo_status" "$#" "$1"
demo_masked() { local demo_result=$(return 9); }
demo_checked() {
  local demo_result
  demo_result=$(return 9)
}
demo_masked
printf 'combined-declaration=%s\n' "$?"
if demo_checked; then exit 99; else printf 'separate-assignment=%s\n' "$?"; fi
BASH

Expect the local value to change from inner to changed, but the global value to remain global. Both supplied arguments print separately, including the empty one. The function returns seven after shifting its list; the outer shell still has two arguments beginning with outer-one. Finally, the combined declaration reports zero while the separate assignment reports nine. These results test the actual calling boundaries rather than assuming every variable called local is isolated.

Sources