Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Bash local Assignment Returns Zero After a Failed Command

Preserve a command substitution failure by separating Bash local declarations from assignments, then verify the status visible to the caller.

By ON Lab3 min readSources checked November 10, 2026 (KST)

If a function runs local value=$(command) and then observes status zero, separate the declaration from the assignment before investigating the command itself. The declaration is a command with its own status. ShellCheck documents this boundary in SC2155, including related cases with export and readonly.

This is a Bash function example, not a portable /bin/sh recipe. It addresses a failure that is already hidden when the declaration finishes. A later logger is not required to trigger it.

Compare the two forms in an isolated shell

Save the following as declaration-status.bash and run bash declaration-status.bash. It uses a synthetic producer, writes no files, and contacts no service. The producer emits a recognizable value and deliberately returns seven. A nonempty value is therefore not evidence of success.

set +e
produce() {
  printf '%s' 'partial-result'
  return 7
}

combined() {
  local value="$(produce)"
  local observed=$?
  printf 'combined: value=%s status=%s\n' "$value" "$observed"
}

separated() {
  local value observed
  value="$(produce)"
  observed=$?
  printf 'separated: value=%s status=%s\n' "$value" "$observed"
}

combined
separated

The expected observation is partial-result in both cases, with status zero for the combined declaration and seven for the separate assignment. The fixture disables errexit only inside the disposable process so both observations can be printed. Do not paste that setting into an application as a general repair.

Return the producer failure deliberately

For an application helper, decide what callers should receive when output is incomplete. This example discards partial output on failure and returns the producer status. The failure branch saves the status before printing its diagnostic.

read_value() {
  local value status
  if value="$(produce)"; then
    printf '%s\n' "$value"
  else
    status=$?
    printf 'producer failed: %s\n' "$status" >&2
    return "$status"
  fi
}

if read_value; then
  printf 'unexpected success\n' >&2
  exit 1
else
  observed=$?
  test "$observed" -eq 7 || exit 1
fi

Run this immediately after the first fixture so produce remains defined. It should print a diagnostic to standard error and pass the final status check. If a real producer needs cleanup, keep the producer result separately and choose an explicit cleanup-failure policy. Do not replace its status accidentally with a successful diagnostic.

Review the declaration boundary, then the caller

Search the affected function for declarations containing command substitutions. SC2155 covers the basic pattern; its documentation also explains an optional additional check for masked returns that are suppressed by default. A clean default lint run alone is insufficient evidence that every declaration preserves an underlying result.

After changing the function, exercise both a successful producer and a failing producer in a safe fixture. Confirm the captured value, standard error and caller-visible status independently. If the function returns the right status but the outer script still reports success, continue with the existing guide on preserving status before logging and aggregating batch failures. That is the next boundary, rather than proof that this declaration fix failed.

Sources & further reading