Course outline · 0% complete

0/29 lessons0%

Course overview →

Decisions: if, test, and exit codes

lesson 8-3 · ~13 min · 23/29

Every command reports success or failure

When any command finishes, it hands the shell an exit code: 0 means success, anything else (1-255) means some kind of failure. The special variable $? holds the exit code of the last command.

This is the shell's version of true/false, and it's what if actually checks. A useful pair to see it with: grep -q pattern file searches quietly (q = print nothing) and only reports through its exit code: 0 if found, 1 if not.

Reading exit codes from the special variable

true and false are tiny commands whose only job is to succeed and to fail. After each one, echo $? reveals the code it left behind. The same trick then shows how grep -q reports its findings without printing any text of its own.

true
echo $?
false
echo $?
echo "no dragons here" > report.txt
grep -q dragons report.txt
echo $?
grep -q unicorns report.txt
echo $?

Output

0
1
0
1

A 0 means success, and for grep -q it means specifically that the pattern was found. Note that dragons genuinely is in the file, since it appears inside the phrase no dragons here, so that search exits 0. The word unicorns is absent, so the final search exits 1.

[ -f notes.txt ]runs and exits…exit 0exit 1then: echo existselse: echo missingif doesn't check true/false, it checks the exit code of a command
The condition of an if is a command. Exit code 0 takes the then branch, anything else takes the else branch.

if, then, else, fi

if [ -f notes.txt ]; then
  echo "notes.txt exists"
else
  echo "notes.txt is missing"
fi

Surprise: [ is itself a command (called test) that exits 0 or 1. That's why the spaces inside the brackets are mandatory. Its most useful checks:

testtrue when
[ -f path ]a file exists there
[ -d path ]a directory exists there
[ "$a" = "$b" ]strings are equal
[ "$n" -ge 5 ]number ≥ 5 (also -eq, -lt, -gt)

Always quote variables inside [ ] ("$age", not $age), or empty values break the test.

[ -f notes ] the condition is a COMMAND it exits with a code 0 success then branch 1-255 failure else branch Because the condition is just a command, any command works there: if grep -q error log; then ... This is also why the spaces inside [ ] are required.
How an if statement decides which branch to run. The bracket test is itself a command that finishes with an exit code, and a code of zero meaning success sends control to the then branch while any code from one to 255 sends it to the else branch. Because the condition is only a command, something like grep -q can be used directly in its place.

Testing a file, then testing a number

The first if checks a file that was just created, and the second reads a number from stdin and compares it. The read -r age line takes one line of input, and this run supplies 21.

touch notes.txt
if [ -f notes.txt ]; then
  echo "notes.txt exists"
else
  echo "notes.txt is missing"
fi
read -r age
if [ "$age" -ge 18 ]; then
  echo "access granted"
else
  echo "too young"
fi

Input

21

Output

notes.txt exists
access granted

Two different kinds of test appear here. -f asks whether a path exists as a regular file, while -ge means greater-than-or-equal and works on numbers only. The quotes around "$age" protect the test from breaking if the variable were ever empty.

if without if: && and ||

Because every command reports an exit code, bash can chain commands on success or failure without writing out a full if block:

ChainMeaning
a && bRun b only if a succeeded, exiting 0
a || bRun b only if a failed, exiting non-zero

So mkdir reports && cd reports enters the directory only if it was actually created, and grep -q error app.log || echo "clean" speaks up only when nothing was found. These two operators appear constantly in real scripts and one-liners, and they are the practical payoff of exit codes even for people who never type $?.

Do not confuse || with the pipe | from unit 5. The pipe moves data between commands regardless of success, while && and || decide whether the next command runs at all.

Three chains, three outcomes

The first chain succeeds and continues, the second fails and falls through to its || side, and the third combines both operators into a compact found-or-not report.

mkdir reports && echo "reports created"
cd missing-dir 2> /dev/null || echo "could not enter missing-dir"
grep -q pear fruit.txt 2> /dev/null && echo "found pear" || echo "no pear today"

Output

reports created
could not enter missing-dir
no pear today

Each line printed exactly one message, chosen by an exit code rather than by any explicit test. The 2> /dev/null parts use lesson 5-1's stderr redirection to hide the expected error messages, so only the script's own words appear. Creating a fruit.txt containing pear beforehand would flip the last line to found pear.

command1 && command2 runs command2 only if command1 succeeded. The shell checks the first command's exit code, and a 0 allows the second to run while anything else skips it.

|| is the mirror image, running its right-hand side only on failure. Neither should be confused with the pipe |, which sends output from one command into the next and ignores exit codes entirely. && and || pass along control, whereas | passes along data.

if [ -f notes.txt ] needs spaces after [ and before ] because [ is genuinely a command, and commands need whitespace separating them from their arguments.

[ is a real program, equivalent to test, whose final argument must be ]. Read that way, [ -f notes.txt ] is a command with three arguments, and as with every command since lesson 1-3, arguments are separated by spaces. Writing [-f instead makes the shell look for a command literally named [-f, which fails with command not found.

This script is a password gate. read -r word takes one line from stdin, a string comparison decides which branch runs, and each branch prints a different message. The test input is open sesame.

read -r word
if [ "$word" = "open sesame" ]; then
  echo "the cave opens"
else
  echo "nothing happens"
fi

Input

open sesame

Output

the cave opens

What the syntax requires

  • String comparison inside [ ] uses a single =, unlike many programming languages that would use ==.
  • Both sides are quoted, as in [ "$word" = "open sesame" ], which keeps the two-word value as one argument and survives an empty variable.
  • The block structure is fixed: if ...; then, the true branch, else, the false branch, and fi to close it.