skip to content

You maintain a growing set of shared bash function libraries, and an incident traced back to a helper whose undeclared variable overwrote a caller's value mid-loop. Given that bash variables are global by default and its scoping is dynamic, what conventions would you mandate to stop function state from colliding, and what would tell you the codebase has outgrown bash?

level: principalimportance: should knowfreq 28%

answer

  1. one namespace, no compiler to help
  2. blanket rule beats case-by-case judgment
  3. prefixed and read-only shared state
  4. the linter has a real blind spot here
  5. exit signals, not a line count

basics

~20 s

Mandate that every function declares all its working variables local on entry, reserve a small set of prefixed read-only globals for configuration, pass data as explicit arguments, and enforce it with linting and review. Move off bash when the logic needs real data structures, error handling or testability.

solid answer

~50 s

The root cause is structural: bash has one variable namespace and dynamic scoping, so any name a function forgets to declare `local` is reachable — and writable — by everyone above and below it on the call stack, with no diagnostic. The conventions that actually hold are few and enforceable: every function declares all of its own variables `local` on the first lines, loop counters included; genuinely shared state is a short list of `readonly` globals with a library prefix; functions receive their inputs as positional parameters rather than reading whatever a caller happened to define; and function names are namespaced by library, such as `deploy::retry`. Enforce with ShellCheck in CI and a review checklist, and be honest that ShellCheck cannot tell you a variable *should* have been local — that gap is why the convention needs human gates and test coverage. The exit signal is when the work needs structured data, real error propagation or unit-testable units.

code

bash · 13 lines
bash
#!/usr/bin/env bash
# lib/deploy.sh - namespaced functions, all working state declared local
readonly DEPLOY_TIMEOUT=30          # deliberate, prefixed, immutable

deploy::retry() {
  local attempts=$1 cmd=$2 i rc     # loop counter and status are local too
  for (( i = 1; i <= attempts; i++ )); do
    "$cmd" && return 0
    rc=$?
    printf 'attempt %d/%d failed (rc=%d)\n' "$i" "$attempts" "$rc" >&2
  done
  return 1
}

go deeper

for a junior

Take away the habit itself: declare every variable your function uses with local, including loop counters, and pass values in as arguments instead of reading whatever the caller left behind.

for a middle

Be able to explain why the convention exists - one namespace plus dynamic scoping means a missing local is a silent write into someone else's variable - and name what ShellCheck does and does not catch.

for a senior

Show how you would make the rule stick across a team: linting in CI, a one-line review checklist, a library skeleton, and tests that are only writable because functions take arguments and print results.

for a principal

Own the whole call: rank the interventions by cost and effect, state the residual risk that bash cannot enforce encapsulation, and define the observable signals that justify migrating the logic out of shell rather than deciding by taste.

## Name the structural cause before the conventions A senior answer starts by refusing to call this a coding slip. Bash gives you one variable table for the whole shell and resolves names dynamically from the live call stack. That combination means: - a function that omits `local` writes into the shared table, so it can overwrite a caller's value; - a function that reads an undeclared name may silently pick up whatever a caller happened to leave in scope; - neither direction produces a warning at parse time, at call time, or at exit. There is no module system to hide behind and no compiler to catch it. So the defence has to be convention plus tooling, and it has to be cheap enough that people follow it under deadline pressure. ## The conventions that hold **1. Every function declares its own state, on entry.** Not just the interesting variables — all of them, including `i`, `tmp`, `line` and `f`. A single declaration line at the top of the body makes the omission visible in review: ```bash deploy::retry() { local attempts=$1 cmd=$2 i rc ... } ``` The reason to make it a blanket rule rather than a judgment call is that the collisions happen precisely on the boring names nobody thinks about. **2. Shared state is a short, prefixed, read-only list.** Configuration that genuinely must be global gets a library prefix and `readonly` so a stray assignment fails loudly rather than silently: `readonly DEPLOY_TIMEOUT=30`. If a function needs to mutate a global, it should be the exception, written explicitly with `declare -g` (bash 4.2+) and reviewed as such — not a plain assignment that looks local but is not. **3. Inputs come in as arguments, never as inherited visibility.** Dynamic scoping makes it *possible* for a helper to read a caller's local; treat doing so as a defect. It is an undocumented dependency that works until someone calls the helper from a second place, and it makes the function untestable in isolation. **4. Namespace the functions too.** Bash allows `::` in function names, and a `library::function` convention (used by, among others, Google's shell style guide) prevents two sourced libraries from silently redefining each other's helpers — the function-level version of exactly the same collision. **5. Outputs are explicit.** Print to stdout and let the caller capture it, or take the destination variable name as a parameter, rather than assuming a caller will read a global your function set. Which of those mechanisms to use is a separate design decision, but the rule here is that the contract is written down in the function signature. ## Making the rules stick Conventions decay unless something checks them: - **ShellCheck in CI, failing the build.** It catches the adjacent classes reliably — SC2155 for `local x=$(cmd)` masking a status, SC2168 for `local` outside a function, SC2086 for unquoted expansions. Be precise in an interview: ShellCheck does *not* flag a variable that should have been declared local. That blind spot is real, and pretending otherwise is worse than acknowledging it. - **A review checklist with one shell-specific line**: "every variable in a new function is declared local". One line, mechanically checkable, no argument. - **Tests.** A function that takes arguments and prints results is testable with a harness such as bats-core; a function that reads and writes globals largely is not. Testability and namespace hygiene push in the same direction, which is a useful thing to point out. - **A shared library skeleton** that new files are copied from, so the conventions are the path of least resistance rather than a document nobody reads. ## When bash has outgrown the job The honest half of this answer is knowing the exit criteria, and stating them as observable signals rather than a line count alone: - **The data has structure.** The moment you are parsing JSON, tracking records with more than one field, or building anything you wish were a struct, arrays and strings stop being adequate. - **Errors must be handled, not just detected.** Bash gives you exit statuses and a set of errexit special cases; if the work needs retries with context, partial rollback, or errors that carry a payload, you are simulating a language feature. - **Correctness now depends on tests you cannot easily write.** If the logic is worth unit testing and the shell shape fights you, that is the signal. - **The script has become the interface others build on.** Shared globals and dynamic scope make a stable contract hard to guarantee across teams. What stays in bash is what bash is genuinely best at: process orchestration, glue between real tools, and the thin entrypoint that sets up an environment and hands off. A reasonable stance is that a shell library is fine while it is orchestration, and that the migration should be planned when it starts encoding business rules — moving the logic into a language with lexical scope and a test framework, while leaving a small shell wrapper in place. ## The framing that reads as principal-level Rank the interventions by cost and effect: enforce `local` everywhere and put ShellCheck in CI first, because those are nearly free and remove most of the class; adopt naming and namespacing next, because it needs coordination; plan the migration last, because it costs real time and should be justified by the signals above rather than by taste. And say plainly what residual risk remains after all of it — bash cannot enforce encapsulation, so the discipline is only as strong as the review gate behind it.

  • Can ShellCheck enforce the rule that every function-internal variable is declared local?
    No, and it is worth saying so directly. ShellCheck catches related defects such as SC2155 for masked return values and SC2168 for `local` outside a function, but it does not flag a plain assignment that should have been local. That gap is exactly why the convention needs a review checklist and a shared skeleton rather than tooling alone.
  • Why insist on declaring even trivial loop counters local?
    Because the collisions happen on trivial names. Two authors both reach for `i`, `tmp` or `f`, and dynamic scoping means the callee's assignment lands on the caller's binding mid-loop with no warning. A blanket rule is mechanically checkable in review; a judgment call about which names are risky is not.
  • How do you decide a shell codebase should move to another language?
    Use observable signals rather than a line count: the data has structure you wish were a record, errors need context and rollback rather than a status code, the logic is worth unit testing but resists it, or the script has become an interface other teams depend on. Keep bash for process orchestration and the entrypoint that hands off.
  • If some state genuinely has to be shared across functions, how should that be expressed?
    As a short, explicitly named set: a library prefix and `readonly` so accidental writes fail loudly, declared in one place at the top of the library. A function that must mutate a global should say so with `declare -g` in bash 4.2 or later, so the intent is visible in the diff rather than indistinguishable from a forgotten `local`.

saying these in an interview costs you the question

  • Treats the incident as one careless commit rather than a structural risk
  • Claims ShellCheck will catch every missing local declaration
  • Relies on a naming convention alone with no enforcement gate
  • Uses inherited caller variables as an intentional calling convention
  • Judges the move off bash purely by script line count

context