Exit status and tests

What $? tells you, exit codes 1/2/126/127/128+n, and [[ ]] vs [ ].

Beginner30 min · lesson 4 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (3 files, 1 KB): bo-tests.tar.gz. Unpack it with tar -xzf bo-tests.tar.gz, which creates bo-tests/. SHA-256: bb5b2839738864a6b936db2e82bbd01615155e271c849d08841a248a182ef0fe

Every decision a script makes rests on two things: the exit status of the command it just ran, and tests that compare values or check files. This lesson teaches you to read both. You will keep "no match" apart from "could not run", recognise the numbers Bash sets itself (126, 127 and 128 plus a signal number), write string, number and file tests with [ ] and [[ ]], and check a number that comes from outside the script before any arithmetic touches it, because Bash arithmetic can run a command hidden in the value. The examples run in ~/bash-ops/tests, next to a small SSH log. Save it there as auth.log: it has three failed password attempts, one accepted key login and no Invalid user line, and the results below depend on exactly those lines.

auth.log
2026-09-27T09:12:44.103221+00:00 web01 sshd-session[2211]: Failed password for invalid user admin from 203.0.113.7 port 50214 ssh2
2026-09-27T09:12:47.591870+00:00 web01 sshd-session[2211]: Failed password for invalid user admin from 203.0.113.7 port 50214 ssh2
2026-09-27T09:13:02.004518+00:00 web01 sshd-session[2230]: Accepted publickey for deploy from 198.51.100.24 port 41822 ssh2: ED25519 SHA256:3xAmPlEfInGeRpRiNtOnLyFoRtHeLaBxxxxxxxxxxx
2026-09-27T09:13:02.118902+00:00 web01 sshd-session[2230]: pam_unix(sshd:session): session opened for user deploy(uid=1001) by deploy(uid=0)
2026-09-27T09:15:31.662045+00:00 web01 sshd-session[2254]: Failed password for root from 203.0.113.7 port 50388 ssh2
2026-09-27T09:15:33.870311+00:00 web01 sshd-session[2254]: Connection closed by authenticating user root 203.0.113.7 port 50388 [preauth]

Every command ends with a status

The Linux essentials lesson "Pipes, redirection and exit status" introduced $? (0 to 255, 0 for success) and grep -q, whose status is the whole answer: 0 a line matched, 1 the file was read and nothing matched, 2 grep could not do its job, for example because the file is missing. In a script the difference between 1 and 2 matters. Treat them the same and a failed-login check reports "no failed logins" on the morning the log was rotated away: a finding hidden as a clean result. Keep the difference whenever a command gives you one.

$? only ever holds the status of the most recent command. Anything you run in between replaces it, even an echo:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ grep -q 'session opened for user root' auth.log echo "checked auth.log" echo "status: $?"
checked auth.log status: 0
$ grep -q 'session opened for user root' auth.log rc=$? echo "checked auth.log" echo "status: $rc"
checked auth.log status: 1

In the first step grep found no root session and returned 1, but the echo in between succeeded, so $? said 0 by the time it was printed. In the second step rc=$? saved the status on the very next line, and the saved value survives whatever runs later. Save $? immediately, or test the command directly in an if (next lesson) so there is nothing to save.

Statuses the shell sets itself

Some statuses do not come from the program at all, because the program never ran or never finished. Bash fills them in:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ printf 'Rotate the deploy key before Friday.\n' > notes.txt ./notes.txt echo "status: $?"
-bash: line 2: ./notes.txt: Permission denied status: 126
$ sytemctl status ssh echo "status: $?"
-bash: line 1: sytemctl: command not found status: 127
# At an interactive Ubuntu prompt the command-not-found helper also suggests systemctl. The status is still 127.

126 means Bash found the file but could not execute it: the printf line created notes.txt as an ordinary text file, without the execute bit. 127 means no command of that name exists, which in a script is almost always a typo or a tool that is not installed on this machine.

The third kind comes from signals. As the Linux essentials lesson "Signals and job control" showed, a process killed by a signal has no exit status of its own, so Bash reports 128 plus the signal number: 143 for SIGTERM (15), 137 for SIGKILL (9) and 130 for SIGINT (2, Ctrl-C). In a job log, a step that "exited 137" without printing an error of its own was most likely killed, for example by the kernel's out-of-memory killer, so look for what killed it before you look at the script. The lesson "Traps, signals and cleanup" shows how a script can react to these signals.

An exit status is a single byte, so it only holds 0 to 255. Larger numbers are reduced modulo 256:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ bash -c 'exit 300' echo "exit 300 gives: $?" bash -c 'exit 256' echo "exit 256 gives: $?"
exit 300 gives: 44 exit 256 gives: 0

exit 256 reports success. A script that ends with exit $errors reports success when there were exactly 256 errors, so exit with a fixed status you have documented and print the count instead.

Reading an exit status
Set by the command
0
success
1, 2, ...
failures the command documents (grep: 1 no match, 2 error)
Set by Bash
126
found, but could not be executed
127
command not found
128 + n
killed by signal n: 130 INT, 137 KILL, 143 TERM
Statuses are 0 to 255; exit 256 becomes 0. This course uses 0 for success, 1 for "could not do it" or findings, and 2 when it could not run as asked, such as wrong usage.

Ask a question: test, [ ] and [[ ]]

A test is a command that checks one condition and returns 0 when it is true and 1 when it is false, so tests plug into everything that reads exit statuses. The POSIX test command has two spellings, test EXPR and [ EXPR ]; they are the same command, and [ insists that its last argument is ]. Here are the string tests:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ user=deploy empty= [ "$user" = deploy ]; echo "= : $?" [ "$user" != root ]; echo "!= : $?" [ -z "$empty" ]; echo "-z : $?" [ -n "$empty" ]; echo "-n : $?"
= : 0 != : 0 -z : 0 -n : 1

= means equal and != means different; -z is true for an empty string (zero length) and -n for a non-empty one. Numbers are compared with letter operators, because < and > already mean redirection to the shell: -eq, -ne, -lt (less than), -le (less or equal), -gt and -ge:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ count=3 [ "$count" -gt 5 ]; echo "-gt 5 : $?" [ "$count" -le 5 ]; echo "-le 5 : $?" [ "$count" -eq 3 ]; echo "-eq 3 : $?"
-gt 5 : 1 -le 5 : 0 -eq 3 : 0
$ [ -e auth.log ]; echo "-e auth.log : $?" [ -f auth.log ]; echo "-f auth.log : $?" [ -d auth.log ]; echo "-d auth.log : $?" [ -r auth.log ]; echo "-r auth.log : $?" [ -x notes.txt ]; echo "-x notes.txt : $?" [ -s auth.log.1 ]; echo "-s auth.log.1 : $?"
-e auth.log : 0 -f auth.log : 0 -d auth.log : 1 -r auth.log : 0 -x notes.txt : 1 -s auth.log.1 : 1

File tests ask about a path: -e exists, -f is a regular file, -d is a directory, -r, -w and -x whether you may read, write or execute it, and -s whether it exists and is not empty. auth.log exists, is a file and is readable; notes.txt is not executable; auth.log.1 does not exist, so it is not non-empty either. Prefer -r to -e before reading a file: it answers the question the script actually has.

Bash has a second form, [[ ]]. The difference shows in how the shell treats them:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ type test [ [[
test is a shell builtin [ is a shell builtin [[ is a shell keyword

test and [ are builtins: commands like any other, whose arguments go through word splitting and filename expansion before the command sees them. [[ is a keyword, part of Bash's grammar, so Bash reads what is between the brackets itself and does not split variables there. The difference matters as soon as a value contains a space or is empty:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ reply="yes please" [ $reply = yes ]; echo "unquoted: $?" [ "$reply" = yes ]; echo "quoted: $?"
-bash: line 2: [: too many arguments unquoted: 2 quoted: 1
$ path= [ -n $path ]; echo "unquoted -n: $?" [ -n "$path" ]; echo "quoted -n: $?"
unquoted -n: 0 quoted -n: 1

Unquoted, $reply became two words, so [ received yes please = yes and gave up with status 2. The empty case is worse, because it fails silently: with $path empty, the command was just [ -n ]. A test with a single argument is true when that argument is a non-empty string, and -n is one, so "is path non-empty?" answered yes. Quoted, both tests gave the right answer. With [ ], every variable goes in double quotes. [[ ]] does not have these problems:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ reply="yes please" path= [[ $reply = yes ]]; echo "reply: $?" [[ -n $path ]]; echo "-n path: $?"
reply: 1 -n path: 1
$ wanted='web*' host=web01 [[ $host == $wanted ]]; echo "unquoted right side: $?" [[ $host == "$wanted" ]]; echo "quoted right side: $?"
unquoted right side: 0 quoted right side: 1
$ [[ 10 < 9 ]]; echo "10 < 9 : $?" [[ 10 -lt 9 ]]; echo "10 -lt 9 : $?"
10 < 9 : 0 10 -lt 9 : 1

Unquoted variables behave correctly inside [[ ]], but two operators work differently there. On the right of == (or =), an unquoted value is a pattern, so web* matched web01; quote it when you mean the literal text. And < compares strings in sort order, so "10" < "9" is true, like "apple" before "banana". Use -lt for numbers.

Which to use: in a Bash script, [[ ]], which is harder to get wrong. In a #!/bin/sh script, [ ] with every variable quoted, because sh (Dash) has no [[, as the first lesson showed. This course writes [[ ]] from here on.

Numbers from outside the script

Scripts get numbers from places they do not control: arguments, environment variables, config files, another command's output. This script takes a limit as its first argument and raises an alert when auth.log has more failed passwords than that. It has a flaw, marked in its header, which the rest of this section exposes and then fixes:

check-fails.sh
#!/usr/bin/env bash
# Usage: check-fails.sh LIMIT LOGFILE
# Warns when LOGFILE has more than LIMIT failed SSH password attempts.
# Exit status: 0 at or under the limit, 1 over it, 2 wrong arguments.
# UNSAFE: LIMIT reaches an arithmetic test without being checked first.
if [[ $# -ne 2 ]]; then
printf 'usage: check-fails.sh LIMIT LOGFILE\n' >&2
exit 2
fi
limit=$1
log=$2
count=$(grep -c 'Failed password' -- "$log")
if [[ $count -gt $limit ]]; then
printf 'ALERT: %s failed logins (limit %s)\n' "$count" "$limit"
exit 1
fi
printf 'ok: %s failed logins (limit %s)\n' "$count" "$limit"

grep -c prints the number of matching lines. The -- tells grep that no options follow, so a log name starting with - is still read as a name (the lesson "Quoting, word splitting and globbing" explains why that matters). The status follows the convention of the header: 0 under the limit, 1 over it, 2 wrong arguments. With honest input it works:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ chmod +x check-fails.sh ./check-fails.sh 5 auth.log echo "status: $?" ./check-fails.sh 2 auth.log echo "status: $?"
ok: 3 failed logins (limit 5) status: 0 ALERT: 3 failed logins (limit 2) status: 1

Now give it a limit that is not a number. The value below contains $( ), command substitution from the lesson on variables. The command inside is harmless and only prints a word, but it could be any command:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ ./check-fails.sh 'a[$(echo injected >&2)]' auth.log echo "status: $?"
injected ALERT: 3 failed logins (limit a[$(echo injected >&2)]) status: 1

The word injected was printed by code that arrived as data. Inside [[ ]], the operands of -gt, -eq and the other number operators are arithmetic expressions, and so is everything inside (( )). Bash evaluates them as small programs: a name is read as a variable, and a[...] is an element of an array whose index is itself evaluated, which runs the $( ) inside it. The command ran with the script's user and permissions. Then a turned out to be empty, empty counts as 0, and 3 is greater than 0, so the script also raised a false alert. The same happens at the prompt, and [ ] behaves differently:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ x='a[$(echo injected >&2)]' [[ $x -eq 0 ]]; echo "[[ -eq ]]: $?" (( x == 0 )); echo "(( )): $?" [ "$x" -eq 0 ]; echo "[ -eq ]: $?"
injected [[ -eq ]]: 0 injected (( )): 0 -bash: line 4: [: a[$(echo injected >&2)]: integer expected [ -eq ]: 2

[[ -eq ]] and (( )) both ran the command. [ ] requires a plain integer, refused the value and returned 2. That is not a fix on its own, though: inside an if, status 2 is simply "not true", so the unsafe script rewritten with [ ] would print ok for any value it cannot read. Two smaller surprises come from the same rule:

deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ ./check-fails.sh count auth.log echo "status: $?"
ok: 3 failed logins (limit count) status: 0
$ [[ 010 -eq 8 ]]; echo "010 -eq 8 : $?" [[ 08 -eq 8 ]]; echo "08 -eq 8 : $?"
010 -eq 8 : 0 -bash: line 2: [[: 08: value too great for base (error token is "08") 08 -eq 8 : 1
$ [[ 99999999999999999999 -gt 65535 ]]; echo "status: $?" echo $(( 99999999999999999999 ))
status: 0 7766279631452241919

A plain word is read as a variable name. count happens to be the script's own variable, holding 3, so the test became "3 is greater than 3", and the check passes no matter how many failed logins the log holds. A leading zero makes a number octal (base 8), so 010 is 8 and 08 is an error, and an error in a test counts as "false". Numbers are 64-bit, so a 20-digit value silently wraps round to a different number.

The fix is to validate the value before any arithmetic sees it, with a test that does not evaluate it: a regular expression match. [[ $value =~ regex ]] (Bash-only) is true when the regular expression matches. In ^(0|[1-9][0-9]*)$, ^ and $ anchor the pattern to the whole value, and the middle allows either a single 0 or a digit from 1 to 9 followed by any digits. Leave the pattern unquoted, because a quoted right-hand side is compared as literal text. The fixed script:

check-fails.sh (validated)
#!/usr/bin/env bash
# Usage: check-fails.sh LIMIT LOGFILE
# Warns when LOGFILE has more than LIMIT failed SSH password attempts.
# Exit status: 0 at or under the limit, 1 over it, 2 wrong arguments.
if [[ $# -ne 2 ]]; then
printf 'usage: check-fails.sh LIMIT LOGFILE\n' >&2
exit 2
fi
limit=$1
log=$2
# Only a plain decimal number may reach -gt: Bash evaluates the operands of an
# arithmetic test as expressions, and an expression can run commands. Leading
# zeros are refused too, because Bash reads 010 as octal (8) and rejects 08.
if [[ ! $limit =~ ^(0|[1-9][0-9]*)$ ]]; then
printf 'check-fails: LIMIT must be a whole number, got: %s\n' "$limit" >&2
exit 2
fi
if [[ ! -r $log ]]; then
printf 'check-fails: cannot read %s\n' "$log" >&2
exit 2
fi
count=$(grep -c 'Failed password' -- "$log")
if [[ $count -gt $limit ]]; then
printf 'ALERT: %s failed logins (limit %s)\n' "$count" "$limit"
exit 1
fi
printf 'ok: %s failed logins (limit %s)\n' "$count" "$limit"
deploy@web01:~/bash-ops/tests · Ubuntu 26.04 LTS
$ ./check-fails.sh 5 auth.log echo "status: $?" ./check-fails.sh 2 auth.log echo "status: $?"
ok: 3 failed logins (limit 5) status: 0 ALERT: 3 failed logins (limit 2) status: 1
$ ./check-fails.sh 'a[$(echo injected >&2)]' auth.log echo "status: $?" ./check-fails.sh count auth.log echo "status: $?" ./check-fails.sh 08 auth.log echo "status: $?" ./check-fails.sh 5 auth.log.1 echo "status: $?"
check-fails: LIMIT must be a whole number, got: a[$(echo injected >&2)] status: 2 check-fails: LIMIT must be a whole number, got: count status: 2 check-fails: LIMIT must be a whole number, got: 08 status: 2 check-fails: cannot read auth.log.1 status: 2

Honest input behaves exactly as before. The injected value, the variable name and 08 are all refused with a message on standard error and status 2, and injected was never printed, because nothing evaluated the value. The new -r check also turns a missing log into a clear error: without it, grep would fail, count would be empty, and an empty count is 0, which is "ok".

Validate numbers where they enter
Any number that reaches -eq, -gt, $(( )) or (( )) from outside the script (arguments, environment variables, files, command output, read) must pass a digits-only check first. Validate once, at the point where the value enters, and exit 2 with a message when it fails. Switching to [ ] is not a substitute: it refuses the value, but the script carries on as if the answer were "false".

Try this

Write port-ok.sh PORT in a new directory. It prints a usage message and exits 2 unless it gets exactly one argument, then accepts only TCP port numbers from 1 to 65535: validate with [[ $port =~ ^[1-9][0-9]{0,4}$ ]] (no leading zero, at most five digits) and only then test -gt 65535. Errors go to standard error with status 2; a good port prints port 22 ok and exits 0. Run it with each of these and predict the result first:

1. 22: port 22 ok, status 0.

2. 70000: port-ok: out of range: 70000, status 2. It passed the pattern and failed the range check.

3. 022: refused as not a port number, status 2, because of the leading zero.

4. 'a[$(echo injected >&2)]' (in single quotes): not a port number, status 2, and the word injected must not appear.

5. Twenty 9s: not a port number, status 2. The five-digit cap refuses it before arithmetic can wrap it round.

Takeaway

Read exit statuses as specific answers, and save $? on the very next line if you need it later. Use [[ ]] in Bash scripts and quote every variable inside [ ], and let no number from outside the script reach an arithmetic test before a digits-only check has passed.

Quick check
01A maintenance script receives a threshold as its first argument and runs if (( $1 > 100 )); then rotate_logs; fi. The argument is built by another team's tool from user input. What is the real risk, and what fixes it?
Incorrect — Arithmetic context evaluates the value as an expression, it does not compare text. Quotes do not change that: the lesson's (( x == 0 )) ran the command from a variable.
Correct — A value like a[$(cmd)] makes Bash run cmd while evaluating the index. Validating with [[ $1 =~ ^(0|[1-9][0-9]*)$ ]] first means only a plain decimal number is ever evaluated.
Incorrect — [ ] does not run the payload, but it returns 2, which if treats as false, so the script carries on as though the value were under the limit. It still needs validation.
Incorrect — The payload in the lesson contains neither. $( ) needs no separator, which is why a blocklist of characters fails and a digits-only allowlist works.
02A nightly job's log shows that its backup step ended with status 137, and the step printed no error message of its own. What is the most likely explanation?
Incorrect — A command that is not found gives 127, and Bash prints "command not found". 137 is in the 128+n range.
Incorrect — Found but not executable gives 126, with a "Permission denied" message from Bash.
Correct — 137 is 128 + 9. A process killed by a signal cannot print an error or choose a status; Bash reports 128 plus the signal number.
Incorrect — Statuses above 128 are how Bash reports death by signal, and counts do not fit in a status anyway (256 wraps to 0). A program that did this would be breaking the convention.
03A deploy script starts with if [ -n $DEPLOY_TAG ]; then deploy "$DEPLOY_TAG"; else echo "no tag" >&2; exit 2; fi. When DEPLOY_TAG is empty, it calls deploy with an empty tag. Why?
Correct — Unquoted, the empty value disappears and the command is [ -n ]. A single argument is true when it is a non-empty string, and -n is one. Quote it: [ -n "$DEPLOY_TAG" ], or use [[ -n $DEPLOY_TAG ]].
Incorrect — -n tests for a non-empty string; it has nothing to do with numbers. The test went wrong because it never received the variable at all.
Incorrect — Empty and unset variables both expand to nothing here, and [ -n "" ] (quoted) is correctly false. No rule makes tests on unset variables true.
Incorrect — [ never looks up variables; the shell expands them before [ runs. The value was empty, and the lack of quotes made it vanish.

Related