Conditionals and Tests · about 40 min

if and the test command

Why this matters

A script that cannot decide anything is just a list of commands. Real scripts check before they act: does the config file exist, did the user pass an argument, is the disk more than 90% full, did the download succeed. bash's if is unusual compared with other languages: it does not test a true/false value, it runs a command and looks at its exit status. Once that clicks, the rest of this module is details.

if runs a command

The shape:

if COMMANDS; then
    COMMANDS
elif COMMANDS; then
    COMMANDS
else
    COMMANDS
fi

bash runs the commands after if. If the last one exits with status 0 (success), the then block runs. Otherwise bash tries each elif in turn, and if none succeeds, runs else. elif and else are optional; fi (if backwards) closes the statement.

Any command will do, which is the important part:

if grep -q '^learner:' /etc/passwd; then
    echo "user learner exists"
fi

if mkdir /work/cache 2>/dev/null; then
    echo "created cache"
else
    echo "cache already there (or not allowed)"
fi
$ bash check.sh
user learner exists
created cache
$ bash check.sh
user learner exists
cache already there (or not allowed)

This is the same exit-status logic as && and || from module 1, written in a form that does not have the a && b || c trap: when the then block fails, else does not run.

The then may go on its own line instead of after ;. Both forms are common:

if grep -q root /etc/passwd
then
    echo found
fi

The test command, also known as [

Most conditions are not "did a program succeed" but "is this string empty", "is this number bigger", "does this file exist". For that there is a command whose only job is to evaluate an expression and exit 0 (true) or 1 (false): test.

$ test 5 -gt 3; echo $?
0
$ test 5 -gt 8; echo $?
1

test has a second name, [. When called as [, it insists that its last argument is ]. These two lines are identical:

if test -f config.txt; then echo yes; fi
if [ -f config.txt ]; then echo yes; fi

[ is not shell syntax like parentheses in other languages. It is a command name. bash has it as a builtin for speed, and there is even a program file for it:

$ type -a [
[ is a shell builtin
[ is /usr/bin/[

So, after expansion, [ "$n" -gt 3 ] is a command named [ with four arguments: the value of n, -gt, 3, and ]. Every spacing rule follows from that: [ needs a space after it (it is a separate word), every operator and operand is its own word, and ] is a separate final word.

What test can check

Strings:

TestTrue when
[ -z "$s" ]s is empty (zero length)
[ -n "$s" ]s is not empty
[ "$a" = "$b" ]the strings are equal (== also works in bash, but = is portable)
[ "$a" != "$b" ]the strings differ

Integers (only whole numbers; bash has no floating point):

TestMeaning
-eq, -neequal, not equal
-lt, -leless than, less or equal
-gt, -gegreater than, greater or equal

Files:

TestTrue when the path
-eexists (any kind)
-fis a regular file
-dis a directory
-Lis a symbolic link
-sexists and is not empty
-r, -w, -xis readable, writable, executable by you
a -nt ba is newer than b (modification time)

Combining and negating:

FormMeaning
[ ! -f x ]not a file
[ -f a ] && [ -f b ]both (preferred)
[ -f a ] || [ -f b ]either (preferred)

test also has -a and -o for and/or inside one bracket, but they are ambiguous in some cases and POSIX marks them obsolete; use && and || between separate tests instead.

Why are numbers compared with -lt and not <? Because < and > are redirection operators. [ 5 > 3 ] does not compare anything: bash creates a file named 3, redirects ['s output into it, and runs [ 5 ], which is true because "5" is a non-empty string. Try it in the sandbox and then look at ls.

A worked example

Open the shell for this lesson and create age.sh with nano:

#!/bin/bash
# age.sh: classify an age given as the first argument
if [ $# -ne 1 ]; then
    echo "usage: age.sh AGE" >&2
    exit 2
fi

age=$1
if [ "$age" -lt 13 ]; then
    echo "child"
elif [ "$age" -lt 20 ]; then
    echo "teenager"
else
    echo "adult"
fi
$ bash age.sh 8
child
$ bash age.sh 19
teenager
$ bash age.sh 45
adult
$ bash age.sh
usage: age.sh AGE
$ echo $?
2
$ bash age.sh old
age.sh: line 9: [: old: integer expression expected
age.sh: line 11: [: old: integer expression expected
adult

The last run shows a weakness: [ reports an error for a non-number, returns a failure status (2), and if treats any non-zero status as false, so execution falls through to else. Lesson 3 shows how to validate input before doing arithmetic on it.

Why you quote inside [ ]

Because [ is a normal command, its arguments go through word splitting and globbing like any other command (module 7). An unquoted empty variable simply disappears:

$ name=
$ [ $name = admin ] && echo yes
bash: [: =: unary operator expected
$ [ "$name" = admin ] && echo yes
$

Unquoted, [ received = admin ]: three arguments, which it reads as "unary operator =" and fails. Quoted, it received "" = admin ], a valid comparison that is simply false. A value with a space does the opposite damage: name="a b" gives [: too many arguments. Rule: always double-quote variables inside [ ].

Common mistakes

Exercises

The quizzes check that you think of [ as a command and that you choose string or numeric operators deliberately. The two code exercises are run with several arguments, including edge cases and an argument with a space in it, and the exit status is checked too.

Recap

Going further: help test lists every operator. Then try [ -t 0 ] && echo terminal interactively and inside a pipeline (echo | bash -c '[ -t 0 ] && echo terminal || echo pipe'): -t tests whether a file descriptor is connected to a terminal.

Exercises

Exercise 1 · quiz

What is [ ?

Someone writes if [$name = "admin"]; then and gets an error. Why do the spaces around [ and ] matter?

Answer
  • [ is a command (another name for test) and ] is its last argument, so both must be separate words.

[ is an ordinary command name, exactly like test, with the one extra rule that its last argument must be ]. Without the space, bash reads [admin (after expansion) as the command name and reports bash: [admin: command not found. Without the space before ], test complains [: missing ']'. Thinking "[ is a command" explains every spacing rule.

Exercise 2 · quiz

Strings or numbers?

Which tests are true? Select all that apply.

Answer
  • [ 10 -gt 9 ]

  • [ 007 -eq 7 ]

  • [ abc != ABC ]

-gt, -eq and friends compare integers: 10 is greater than 9, and 007 equals 7 as a number. =, != and the escaped \> compare strings, character by character: as strings "10" sorts before "9" (because "1" comes before "9"), and "007" is a different string from "7". Case matters in string comparison, so abc != ABC is true. Pick the operator that matches what your data means.

Exercise 3 · write code

Does the file exist?

Write a script that takes one argument, a path, and reports on it:

where PATH is the argument exactly as given. If no argument is given at all, print usage: script.sh PATH and exit with status 2.

The working directory contains a file notes.txt and a directory docs.

File: script.sh

Starting point

#!/bin/bash
# check "$1" with if / elif / else and test

Example with arguments notes.txt

expected output

notes.txt is a file

Example with arguments docs

expected output

docs is a directory
Hint 1

[ -f "$1" ] is true for a regular file, [ -d "$1" ] for a directory. $# is the number of arguments.

Hint 2

Check $# first and exit 2. Then if [ -f "$1" ]; then ... elif [ -d "$1" ]; then ... else ...; exit 1; fi.

Hint 3

Quote "$1": one test case uses a name with a space in it.

Reference solution
#!/bin/bash
if [ $# -eq 0 ]; then
    echo "usage: script.sh PATH"
    exit 2
fi
if [ -f "$1" ]; then
    echo "$1 is a file"
elif [ -d "$1" ]; then
    echo "$1 is a directory"
else
    echo "$1 does not exist"
    exit 1
fi

Exercise 4 · write code

Pass or fail

Write a script that takes a score from 0 to 100 as its only argument and prints one word:

Always exit 0. Use if/elif/else and numeric tests (-ge, -lt, ...).

File: script.sh

Starting point

#!/bin/bash
score=$1

Example with arguments 95

expected output

excellent

Example with arguments 90

expected output

excellent
Hint 1

Test the highest band first: the first true branch wins and the rest are skipped.

Hint 2

if [ "$score" -ge 90 ]; then echo excellent; elif [ "$score" -ge 50 ]; then echo pass; else echo fail; fi

Reference solution
#!/bin/bash
score=$1
if [ "$score" -ge 90 ]; then
    echo excellent
elif [ "$score" -ge 50 ]; then
    echo pass
else
    echo fail
fi