11/02/2026
Bash $? After a Failed if Is 0, Not Your Exit Code
Short answer: in Bash, $? read after fi is the status of the whole if statement, not of the command you tested. When the test fails and there is no else, that status is 0, so STATUS=$? on the line after fi always captures 0. Capture the code where the command runs instead: STATUS=0; cmd || STATUS=$?.
The gate that could not fail
One of my deploy gates runs a site's real .htaccess under real Apache in a container before anything ships. On 09/07/2026 it turned out that gate had never blocked a deploy, because its wrapper script read the exit code like this. The wrapper was written that way on 08/31/2026, when I folded my deploy scripts into one kit, so it went about six and a half days unable to fail:
set -euo pipefail
if node scripts/dev/apache-harness.mjs; then
exit 0
fi
STATUS=$?
if [ "$STATUS" -eq 3 ]; then
printf ' no container runtime for the Apache harness (try: podman machine start)\n'
exit 3
fi
exit "$STATUS"
It reads well. If the harness passes, exit 0. If not, grab the code, treat 3 as "skipped", and pass anything else on. But STATUS was always 0, so the script exited 0 whatever the harness said. It surfaced only when a new force-HTTPS rule made the harness fail for real: the harness exited 1, and the wrapper reported success.
Two live deploys went out in that window, one to each of two sites. Both changed content only and neither touched an .htaccess, so the gate had nothing it should have caught.
Here is the same shape with a stand-in harness that just exits with the code it is given:
# harness.sh
exit "${1:-1}"
# broken.sh
set -euo pipefail
if ./harness.sh 1; then
exit 0
fi
STATUS=$?
echo "STATUS=$STATUS"
exit "$STATUS"
$ ./broken.sh; echo "broken.sh exit $?"
STATUS=0
broken.sh exit 0
What the Bash manual says
The GNU Bash manual's Conditional Constructs section defines the status of if in one sentence: "The return status is the exit status of the last command executed, or zero if no condition tested true."
The condition's own status is consumed by if to choose a branch. Once fi is reached, $? describes the statement as a whole. With no else, a failed test means no branch ran, so the statement's status is zero. Every case below was run in Git Bash:
| What happened | $? read where |
Value |
|---|---|---|
Test failed, no else |
after fi |
0 |
Test failed, else ran and its last command exited 5 |
after fi |
5 |
Test passed, then ran and its last command exited 4 |
after fi |
4 |
| Test exited 3 | first line inside else |
3 |
if ! cmd where cmd exited 3 |
first line inside then |
0 |
The last row is the second trap. ! negates the status, and the Bash manual's Pipelines section says so: "the exit status is the logical negation of the exit status". So if ! cmd; then echo "failed with $?"; fi reports 0 every time. The real code is gone before the then branch starts.
Why set -e did not catch it
set -e never fires on a command tested by if. The Bash manual's Set Builtin section lists the exemptions: the shell does not exit if the failing command is "part of the test in an if statement", part of a && or || list other than its last command, or inverted with !.
That exemption is why the wrapper used an if in the first place. The obvious alternative dies before it can read anything:
# naive.sh
set -e
./harness.sh 3
STATUS=$?
echo "never printed: $STATUS"
$ ./naive.sh; echo "naive.sh exit $?"
naive.sh exit 3
The script does fail, but the "3 means skipped" branch can never run. The fix has to keep set -e quiet and keep the code.
The fix
Run the command on its own line and capture the status in an ||, which set -e also leaves alone:
# fixed.sh
set -euo pipefail
STATUS=0
./harness.sh "${1:-1}" || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
exit 0
fi
echo "harness failed with exit $STATUS" >&2
exit "$STATUS"
$ ./fixed.sh 1; echo "fixed.sh exit $?"
harness failed with exit 1
fixed.sh exit 1
$ ./fixed.sh 3; echo "fixed.sh exit $?"
harness failed with exit 3
fixed.sh exit 3
$ ./fixed.sh 0; echo "fixed.sh exit $?"
fixed.sh exit 0
STATUS=0 first matters under set -u: when the command succeeds, the || branch never runs, and an unset variable would stop the script.
If you prefer the if, read $? as the very first line of the else, where it still belongs to the condition:
if ./harness.sh 3; then
echo ok
else
STATUS=$? # 3
echo "STATUS=$STATUS"
fi
Any command between else and the capture, even an echo, replaces it.
The same rule in a function
A function returns the status of its last command, and when that command is an if with no else, a failed test returns 0:
check() { if [ -f "$1" ]; then echo found; fi; }
check nope.txt; echo "check exit $?" # check exit 0
A caller writing check config.yml || exit 1 never exits. End such functions with an explicit return 1, or give the if an else that fails.
PowerShell gets if wrong in a different way
Porting the wrapper to Windows PowerShell does not carry the bug across, but it brings its own. Its if does not look at exit codes at all. Per Microsoft's about_If, PowerShell evaluates the condition "as true or false", and for a native program that means its output. Microsoft's about_Automatic_Variables defines the two variables involved: $? "Contains the execution status of the last command", and $LASTEXITCODE "Contains the exit code of the last native program or PowerShell script that ran".
Run in Windows PowerShell 5.1:
if (cmd /c "echo hi& exit 1") { 'took the then branch' } else { 'took the else branch' }
# took the then branch
"after if: `$? = $? LASTEXITCODE = $LASTEXITCODE"
# after if: $? = True LASTEXITCODE = 1
if (cmd /c "exit 0") { 'then' } else { 'else' }
# else
A failing program that prints something takes the success branch, and a succeeding one that prints nothing takes the failure branch. The saving grace is that $LASTEXITCODE survives the if, so the real code is still there to test:
cmd /c "echo hi& exit 1" | Out-Null
if ($LASTEXITCODE -ne 0) { "failed with $LASTEXITCODE" }
# failed with 1
| Bash | Windows PowerShell 5.1 | |
|---|---|---|
What if tests |
the command's exit status | the truthiness of its output |
$? right after a failed test, no else |
0 | True |
| Where the real exit code lives afterward | nowhere, unless you captured it | $LASTEXITCODE, until the next native program |
| Safe pattern | STATUS=0; cmd || STATUS=$? |
run it, then test $LASTEXITCODE -ne 0 |
Prove the wrapper can fail
A gate that has only ever passed has not been tested. The fix to my wrapper was accepted only after it was seen failing: with the harness exiting 1, the wrapper now exits 1 and says the deploy is blocked, where before it exited 0. The stand-in above makes that a ten-second check for any wrapper: feed it a fake command that exits 1, then 3, then 0, and look at what comes out.
This joins a run of the same kind of bug on this site, where something reports success because you asked a different question than you thought:
- grep exit status: 0, 1 and 2 explained, including how
set -etreats a no-match. - grep exits zero when it finds the word FAILED, where a pipeline reported the filter's status instead of the test runner's.
- curl exits 0 on a 404, and what
--failchanges. - A mysqldump piped to gzip made a 20-byte backup that said it worked.
Look for the same shape elsewhere
This was the second bug of its kind in the same kit. Four days earlier I had found a dead rollback in the main deploy script, and the two have different causes:
| Dead rollback in the deploy script | The Apache gate's wrapper | |
|---|---|---|
| The mistake | A status read after an || block whose printf always exits 0 |
$? read after fi, with no else |
| What it hid | Every "content verification failed" path, so the rollback never fired | Any failure of the harness |
| Found | 09/03/2026, while wiring in a new check | 09/07/2026, when a new rule made the harness fail |
| Time in place | "Since the day it was written," in its own code comment | About six and a half days |
On 10/26/2026 I scanned 74 shell scripts across my projects, my project hub and my Claude Code hooks for $? read directly after fi. One turned up, in a release guard. It is not the bug: it has an else, and both branches assign from the command. I did not scan for the other shape, a status read after a fallback block, which is the harder one to grep for.
Questions this raises
Why is $? 0 after my if when the command failed?
Because $? now holds the if statement's status, and an if whose test failed with no else to run returns zero.
How do I get a command's exit code under set -e?
STATUS=0; cmd || STATUS=$?. The || stops set -e from exiting, and the assignment keeps the code.
Does if ! cmd keep the exit code?
No. Inside the then branch, $? is the negated status, which is 0. Capture it with the || pattern instead.

Let my Claude help your Claude
If you want a second pair of eyes on how your own setup uses AI-assisted development, that's what this is: me, personally, for AI specifically. Broader business growth and technology consulting is Brandager's own team.
Researched and drafted with AI, then reviewed and edited by the site's editors.