Skip to content
shellhacks.dev
Testing

ShellCheck SC2181 Check Exit Code Directly Explained

Fix ShellCheck SC2181: test a command's exit status directly with if cmd or if ! cmd instead of checking $? afterwards, and when $? is still right.

6 min read
Summarize in AI
Illustration for "ShellCheck SC2181 Check Exit Code Directly Explained": a terminal session.

The warning reads Check exit code directly with e.g. 'if mycmd;', not indirectly with $?. It nudges toward a form that is shorter and cannot go wrong when a line is inserted between the command and the check. The examples use Bash 5.2 and ShellCheck 0.10; the general topic is in checking exit status in Bash.

The pattern it flags

Shell
grep -q "ERROR" app.log
if [ $? -eq 0 ]; then
  echo "errors found"
fi

mkdir /srv/app
if [ $? -ne 0 ]; then
  echo "mkdir failed" >&2
  exit 1
fi

Both blocks run a command and then test its status on the next line. If someone adds an echo between them, $? now holds the status of echo, and the check silently tests the wrong thing. The two-line form is also longer than it needs to be, since if already tests a command's status.

The direct forms

Shell
if grep -q "ERROR" app.log; then
  echo "errors found"
fi

if ! mkdir /srv/app; then
  echo "mkdir failed" >&2
  exit 1
fi

mkdir /srv/app || { echo "mkdir failed" >&2; exit 1; }

if runs the command and treats status 0 as true. ! negates it. || runs the right side only when the left fails, and a brace group lets you do several things there. -q makes grep print nothing and stop at the first match, so only the status is produced; see grep exit status in scripts. The command's own error output still appears, unless you redirect it.

When you need the number

Shell
rsync -a src/ dest/
rc=$?
case $rc in
  0)  echo "ok" ;;
  23) echo "partial transfer" >&2 ;;
  *)  echo "rsync failed with $rc" >&2; exit "$rc" ;;
esac

Some programs use different codes for different outcomes, and then the number matters. Copy $? to a variable on the line right after the command, and use the variable. ShellCheck does not flag this form, because the status is saved immediately. The meaning of codes is in Bash exit codes and the Linux exit codes list.

With set -e and pipes

Shell
set -e
if ! ./deploy.sh; then
  ./rollback.sh
fi

set -o pipefail
if ! curl -fsS "$url" | jq -e '.ok' >/dev/null; then
  echo "check failed" >&2
fi

Under set -e, a command used as the condition of if does not stop the script when it fails, which is what you want here. For a pipeline, the status is that of the last command unless pipefail is on, and ${PIPESTATUS[@]} holds the status of each stage, see PIPESTATUS.

Why the extra line is a bug waiting to happen

The $? variable holds the exit status of the most recent foreground command, and "most recent" is the whole problem. Any command that runs between the one you care about and the test overwrites it. That includes commands nobody thinks of as commands: a [ ] test, an echo added for debugging, a function call, even an assignment that contains a command substitution. A script that works today can break the day someone adds a log line, and because the broken version still runs without an error, the fault shows up as wrong behaviour much later.

The same hazard appears inside a single if when the test itself is a command. In if [ $? -eq 0 ], the bracket test runs after the status was captured as text, so that case is safe, but a nested form such as [ -f x ] && echo found; if [ $? -eq 0 ] tests the status of the echo, not the file test. Reading code like that takes more effort than the direct form, and the direct form has no state to track.

Rewriting the common shapes

Most flagged code falls into a handful of shapes, and each has a mechanical rewrite. A success branch becomes if cmd; then. A failure branch becomes if ! cmd; then. A one-line guard becomes cmd || { echo "failed" >&2; exit 1; }. A command that should only run on success becomes cmd && next. Loops work the same way: while read -r line; do already tests the status of read, and until ping -c1 host; do sleep 1; done retries until the command succeeds, with no variable at all.

One shape deserves care. When the condition is a pipeline, the status is that of the last command, so if ! cmd1 | cmd2; then ignores a failure of cmd1 unless pipefail is set. People often reach for $? to "see more", but it carries the same single value. If the earlier stage matters, use ${PIPESTATUS[0]} immediately after the pipeline, or restructure so the first command writes to a temporary file whose status can be checked on its own.

The cases ShellCheck lets through

The check only fires when the very next test reads $?. That is why saving it to a variable on the following line passes: rc=$? is itself the test of "what was the status", and from then on the number is stable. The rule does not claim that every use of $? is wrong. It claims that a check that could be written as a condition should be.

There are good reasons to keep the number. A script that wraps another tool and must return that tool's exit code can do cmd; exit $?, or exec cmd when nothing needs to happen afterwards. A cleanup function that runs on trap ... EXIT reads $? at its first line so that it can log the original status and then restore it. In both cases the value is used right away and on purpose, and neither pattern is a style problem.

Command substitution and the masked status

A related trap lies close by. In local out=$(cmd) and export out=$(cmd), the status of the assignment is the status of local or export, which is almost always zero, so the failure of cmd is lost. ShellCheck reports that as a separate warning, SC2155, and the fix is to split the line: declare first, then assign, then test. With the split in place, if ! out=$(cmd); then is the direct form of the same check and keeps the output in a variable. It is the same idea applied to captured output: put the command in the condition, and let the shell do the status test.

When a script is being converted in bulk, run ShellCheck with -f diff to see the suggested changes, but read each one. The tool rewrites the syntax correctly, yet only a person knows whether the old code relied on a specific code such as 2 for a partial result. Where it did, keep the number and add a comment naming what each value means.

A small checklist for reviewing a flagged line

When ShellCheck reports this warning on a file full of old code, a short routine keeps the review fast. First, find the command that sets the status. If it sits on the line directly above the test, apply the direct rewrite. Second, check whether anything between the command and the test could change the value; if so, the code already has a bug, and the rewrite fixes it. Third, look at what the branches do. A failure branch that prints a message and exits is a good candidate for the || form, and one that does several unrelated things reads better as an if ! block. Fourth, run the script's tests, or at least run the changed function with both a passing and a failing input, because a mechanical edit that turns [ $? -ne 0 ] into if ! cmd flips the logic if the original test was written the other way round.

Keep the commit small. Bulk-fixing one warning across a repository produces a diff that nobody reads closely, and the one place where the old code relied on the status being checked later is the place that breaks. Fix a script at a time, and mention the warning code in the commit message so the history explains the change.

Where this does not apply

When the status must be tested several times, or after a command with its output captured, out=$(cmd); rc=$? is correct. In POSIX sh the same rules hold, and if cmd; then works everywhere. See the ShellCheck wiki page for SC2181 and the Bash manual on exit status.

Also inDebugging
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.