Exit codes & testing

$?, [[ ]], and && / || logic.

Beginner16 min · lesson 4 of 14

A command that finishes without telling you whether it worked is like a delivery driver who drops a box and walks off without a word. Did it reach the right door? You have no idea. Bash (the shell that reads and runs the commands you type on most Linux servers) refuses to leave you guessing. The moment a command finishes, it hands back a small number that says how things went. Learn to read that number and you can write scripts that notice failure and stop, instead of charging ahead and making a mess. That is the whole difference between a backup script that warns you the disk was full and one that quietly saves nothing for six months.

Every command hands back a number

That number is called the exit code, and it runs from 0 to 255. The rule feels backwards until it sticks: 0 means success, and any non-zero number means something went wrong. It works like a golf score, where zero problems is the win and a higher number is a louder, more specific complaint. The ceiling is 255. If a script tries to leave with code 256, the number wraps back around to 0, which reads as success, so keep any code you invent yourself between 1 and 255. After anything runs, a special variable named $? (say it 'dollar question-mark') holds the exit code of that last command, and you read it with echo.

~/secopslog — bash
$ ls /etc/passwd > /dev/null echo $? ls /etc/nope 2> /dev/null echo $?
0 2

The first ls found the file, so it reported 0. The second could not, so it reported 2. Notice that I threw the error message away with 2> /dev/null, and the exit code still told the truth. That is the point worth holding onto. The text a command prints on your screen is written for humans to read. The exit code is written for your script to act on. Even when a command runs in total silence, its exit code is still talking.

Different failures, different numbers

A non-zero code is not a shrug that means 'broke somehow'. Well-built tools give different failures different numbers and write them down. grep (a text-search tool whose name comes from an old editor command meaning 'search everywhere for a pattern and print it') is a clean example. It returns 0 when it finds your pattern, 1 when the pattern is absent, and 2 when something actually broke, like the file not being there. The -q flag means quiet: print no matching lines, only set the exit code.

~/secopslog — bash
$ grep -q "root" /etc/passwd echo "found root: $?" grep -q "ghost" /etc/passwd echo "found ghost: $?" grep -q "root" /etc/nope echo "bad file: $?"
found root: 0 found ghost: 1 grep: /etc/nope: No such file or directory bad file: 2

The gap between 1 and 2 matters more than it looks. A pattern that is absent (code 1) might be perfectly normal, an empty log with nothing to worry about. A file that has vanished (code 2) is a real problem you probably want to halt for. Flatten every non-zero code into one generic 'error' and you lose that difference, which is exactly the kind of blind spot an attacker who quietly deletes a log file is counting on. Notice too that the error text on the third line came from grep's own complaint, printed to your screen, while the number 2 is the part your script reads.

$? is a sticky note the next command overwrites
The $? variable only ever holds the exit code of the single most recent command. The next thing you run replaces it, even a harmless echo. So if you plan to react to a command's result, grab it on the very next line into your own variable, like rc=$?. A classic bug: run the important command, print a friendly status message, then check $? and act on it. By then $? belongs to the echo, which almost always succeeds, so your check silently reads 0 and your error handling never fires.
~/secopslog — bash
$ grep -q "ghost" /etc/passwd echo "checked the file" echo $?
checked the file 0

The grep on the first line exited 1 because 'ghost' is not in the file, yet the last line prints 0. That 0 belongs to the echo in the middle, which happily succeeded and painted over the result you cared about. This is why you capture the code immediately, before anything else has a chance to run.

Asking yes/no questions with [[ ]]

Scripts constantly need to ask a fact: Is this variable empty? Is this number too high? Does this file exist? Bash answers with a test, written inside double square brackets, [[ ... ]]. A test works like a bouncer checking one thing at the door, and it reports back the same way everything else does, as an exit code: 0 if the test passed (true), 1 if it failed (false). You rarely read that code by hand. You hand the test straight to if, which reads it for you.

String tests compare text. == asks are these equal, != asks are these different, -z asks is this empty (z for zero length), and -n asks is there anything here at all (n for non-empty). Checking for empty earns its keep: a blank variable is often the fingerprint of something upstream that already failed, and acting on it without looking is how a script ends up deleting the wrong path.

~/secopslog — bash
$ name="alice" [[ "$name" == "alice" ]]; echo $? [[ "$name" != "root" ]]; echo $? token="" [[ -z "$token" ]]; echo "token empty? $?" [[ -n "$name" ]]; echo "name set? $?"
0 0 token empty? 0 name set? 0

For DevSecOps work (development, security, and operations handled by one team instead of three), that empty check is a guard rail. If $token came back blank because an API (application programming interface, the way one program asks another for something) call failed, you catch it here before you ever use it as a password, a header, or a filename.

Numeric tests compare whole numbers, and they spell things out with letters: -eq (equal), -ne (not equal), -lt (less than), -gt (greater than). The letters exist because < and > already mean 'redirect a file' to the shell, so Bash needs different words for the comparisons. Reach for these when you count things: failed logins, open ports, days left before a certificate expires.

~/secopslog — bash
$ fails=3 [[ "$fails" -eq 3 ]]; echo $? [[ "$fails" -lt 5 ]]; echo $? [[ "$fails" -gt 10 ]]; echo $?
0 0 1

3 does equal 3 (0), 3 is under 5 (0), and 3 is not over 10, so that last test fails with 1. A real use writes itself: raise an alert when failed logins cross a line, with something like [[ "$fails" -gt 10 ]] && lock_account.

File tests ask about files on disk. -f is this a regular file, -d is this a directory, -e does it exist at all, and the permission trio -r, -w, -x ask can I read it, write it, or run it. Those permission tests answer what your script is actually allowed to do, which is a sharper and safer question than whether the file merely exists.

~/secopslog — bash
$ touch config.yml mkdir logs [[ -f config.yml ]]; echo "regular file? $?" [[ -d logs ]]; echo "directory? $?" [[ -e config.yml ]]; echo "exists? $?" [[ -w config.yml ]]; echo "writable? $?" [[ -x config.yml ]]; echo "executable? $?"
regular file? 0 directory? 0 exists? 0 writable? 0 executable? 1

That last 1 is correct and reassuring. A configuration file is not supposed to be executable, and -x tells you so. A config file that suddenly passes -x is a small red flag worth noticing.

Why [[ ]] beats the old [ ]

You will meet older scripts that use single brackets, [ ... ]. They do a similar job, so why prefer the double kind? Because single brackets are a trap for any value an attacker or a sloppy input might reach. [ is not really part of the language. It is an ordinary command with a funny name, so the shell splits its arguments apart on spaces and expands any * into filenames before [ ever sees them. Double brackets, [[ ]], are built into Bash itself, so they skip that risky rewriting.

login.sh
#!/usr/bin/env bash
reply="yes please"
if [ $reply = yes ]; then
echo "access granted"
fi
~/secopslog — bash
$ bash login.sh
login.sh: line 3: [: too many arguments

Because $reply held two words and the single brackets did nothing to protect it, [ saw four arguments where it wanted three and gave up. In a login or deploy check, a loud crash is the lucky outcome. The quieter danger is a value shaped so that its split-apart pieces line up into a test that passes when it should not, handing out access it should refuse. Double brackets treat $reply as one whole value no matter what is packed inside it, so the same input fails the comparison cleanly.

login2.sh
#!/usr/bin/env bash
reply="yes please"
if [[ $reply == yes ]]; then
echo "access granted"
else
echo "access denied"
fi
~/secopslog — bash
$ bash login2.sh
access denied

Chaining checks with && and ||

Two little symbols turn exit codes into decisions, and they read almost like English. && means 'and then, only if that worked'. || means 'or else, if that failed'. So make_backup && upload_backup uploads only when the backup succeeded, and ping -c1 host || page_oncall wakes a human only when the ping failed. Bash picks which side to run by reading the exit code on the left: a 0 lets && proceed, and any non-zero fires ||.

~/secopslog — bash
$ grep -q "root" /etc/passwd && echo "root account present" grep -q "ghost" /etc/passwd || echo "no ghost account, all clear"
root account present no ghost account, all clear

This is the everyday shape of grep -q pattern file && do_something. The -q keeps grep silent so the only thing that carries meaning is its exit code, and && acts on it. String a few together and a whole safety check fits on one line: [[ -f backup.tar ]] && upload runs the upload only when the backup file is really there.

One number decides the road
A command finishes
writes its exit code into $?
exit 0
Success
if runs its body; && continues to the next step
exit 1 to 255
Failure
if skips its body; || runs the fallback
Zero is the only success. Every other value from 1 to 255 is a specific failure your script can branch on.
Quick check
01You add this line to a deploy script: grep -q "FAILED" deploy.log && ./rollback.sh . When does rollback.sh actually run?
Incorrect — && is conditional, not automatic. It runs the right side only when the left side's exit code is 0.
Correct — grep -q exits 0 when it finds the pattern, and && runs the right side only on a 0 exit, so a found failure triggers the rollback.
Incorrect — That behavior belongs to ||, which runs its right side on a non-zero exit. && is the opposite.
Incorrect — -q hides the matched text but still sets the exit code, and the exit code is exactly what && reads.
02The lesson recommends the double-bracket test `[[ ... ]]` over the older single-bracket `[ ... ]`. What is the main reason?
Correct — the login.sh example crashes with 'too many arguments' precisely because [ received split-apart words.
Incorrect — speed is not the issue; argument handling is.
Incorrect — `[` still exists and works; it is just riskier with untrusted values.
Incorrect — both can do numeric tests with -eq and its friends.
03A script runs `backup_db` (which fails, exiting 1), then `echo 'backup step done'`, then `if [[ $? -ne 0 ]]; then echo 'backup failed'; fi`. The backup fails, yet 'backup failed' never prints. Why?
Incorrect — the scenario states backup_db failed with exit 1.
Incorrect — `[[ $? -ne 0 ]]` is valid; -ne compares numbers fine.
Incorrect — only 0 is success; 1 is a failure code.
Correct — $? only reflects the most recent command, and the intervening echo overwrote the code you cared about.

Related