ShellCheck SC2155 Declare and Assign Separately Explained
Fix ShellCheck SC2155: why local x=$(cmd) hides the command's exit status, and how to declare and assign on separate lines so set -e and checks still work.

The warning reads Declare and assign separately to avoid masking return values, and it describes a real bug that hides failures in functions. The reason is that local, export, declare and readonly are commands with their own exit status. The examples use Bash 5.2 and ShellCheck 0.10; the strict-mode setting involved is covered in Bash strict mode.
The masked status
#!/usr/bin/env bash
set -e
get_version() {
local v=$(cat /nonexistent)
echo "continuing with: '$v'"
}
get_version
echo "script went on"cat: /nonexistent: No such file or directory
continuing with: ''
script went on
The command substitution fails, but the line's status is that of local, which succeeded in creating the variable, so set -e does nothing, and the function carries on with an empty value. Such a bug becomes visible later as a strange result, which is far harder to debug than an immediate stop.
The fix
get_version() {
local v
v=$(cat /nonexistent)
echo "continuing with: '$v'"
}cat: /nonexistent: No such file or directory
With the declaration on its own line, the assignment v=$(cat ...) carries the command's status, so set -e stops the script, and a check such as v=$(cmd) || return 1 works. This is one of the few ShellCheck rules where the safe form is longer, and the extra line is worth it.
Checking the status explicitly
local out
if ! out=$(git rev-parse --show-toplevel 2>/dev/null); then
echo "not in a repository" >&2
return 1
fi
readonly ROOT
ROOT=$outAssigning inside an if ! lets you handle the failure in place. For constants, declare the variable read-only after assigning, not in the same statement, so the same masking does not happen. The patterns for returning values from functions are in returning values from functions.
The same for export
export TOKEN=$(cat /run/secrets/token) # SC2155
TOKEN=$(cat /run/secrets/token)
export TOKENSplit the export too. A failure to read the file with the combined form leaves TOKEN empty and exported, and the script goes on to call an API with no token. declare -r and declare -x behave the same way as readonly and export.
Why the shell behaves this way
A command like local v=$(cmd) is parsed as the command local with an argument that happens to contain a substitution. The shell expands the argument first, which runs cmd and captures its output, and then runs the built-in local with the result. The exit status of the whole line is the status of the last command executed, which is local. The status of cmd was available only inside the expansion, and nothing carries it out. A plain assignment like v=$(cmd) is a different form: with no command word, the shell gives the line the status of the last command substitution, so the failure is visible. That is the whole difference, and it is why separating the declaration from the assignment restores the information.
The same applies to every built-in that takes assignments as arguments: export, readonly, declare, typeset and local. For readonly and export at the top level of a script the harm is smaller than inside functions, but the behaviour is identical and so is the fix.
What happens under strict mode
With set -e, a script stops when a simple command fails. A line that ends in a successful local does not fail, so the script goes on with whatever the variable holds. This is a very common reason for strict-mode scripts that "ignore" errors in functions, and it produces failures far from the cause: a missing value that is later used as a path in an rm, an empty hostname in a deploy, an empty version in a tag. Pair the fix with set -u so that an unset variable aborts, and with an explicit check on values that must not be empty, using ${v:?message} to fail with a useful note.
Patterns that keep the line short
Splitting adds a line to every declaration, which people resist. Two habits reduce the friction. Declare all locals together at the top of the function in one statement, with no values, then assign as needed: local out rc line. That reads like a list of the function's variables, and the assignments below carry their own statuses. And write the assignment with the check in the same place, out=$(cmd) || return, so that the common case of "run and bail out" is one line again. When the function must continue after a failure, capture the status on the next line, rc=$?, as in the example for cleanup.
Related masked statuses
The same masking occurs in other places. A pipeline reports the status of its last command unless pipefail is set, so out=$(cmd1 | cmd2) hides a failure of cmd1. A command in the condition of if, in an && or || list, or after ! is exempt from set -e, so a function called in such a context does not stop on errors inside it, even though it works normally elsewhere. Command substitution inside an argument, such as echo "$(cmd)", drops the status in the same way as above. Knowing the list helps in reviewing strict-mode scripts, because the failure is always a status that nobody looked at.
Finding the cases in an existing codebase
To find every occurrence, ask ShellCheck for the code across the repository: shellcheck -f gcc $(git ls-files '*.sh') | grep SC2155. Fix the ones inside functions first, since those mask failures that strict mode would otherwise catch. For each, split the line, then run the script's tests, or exercise the function with a failing input, and confirm that the failure now propagates. Add a regression test for the one or two functions where the old behaviour had hidden a real error, so that the bug cannot come back.
Style guidance for teams
Write the rule into the project style guide in one sentence: declare locals first, assign on a separate line, and check the status of any assignment that runs a command. Reviewers can then point to the rule instead of explaining the mechanism each time, and the editor warning backs it up. Newer contributors benefit most, because the masked status is invisible until a failure happens in production.
Where this does not apply
If the substitution cannot fail, such as local n=$(date +%s), the warning is a style point, and many codebases still split it for consistency. Assignments with a literal value, local n=5, never trigger the rule. In POSIX sh, local is not defined at all, though dash and most shells provide it. See the ShellCheck wiki page for SC2155 and the Bash builtins reference for the exit status of local and declare.
A good habit is to let ShellCheck do the checking in the editor and in CI, so the rule is enforced without anyone remembering it, as in ShellCheck in the editor and pre-commit. In a code review, a function that starts with a block of local declarations followed by assignments is the style this check produces, and reads clearly. A test that runs the function with a failing command, and asserts a non-zero status, catches the regression, see the Bats tutorial.


