Skip to content

Conditionals in Shell


Bash conditionals don’t test “true or false” the way most languages do — they test exit status, the same mechanism you’ve been checking with $? since Chapter 1. An if statement isn’t asking “is this expression true?” — it’s asking “did this command succeed?” Once that clicks, a lot of Bash’s conditional syntax stops looking arbitrary. This chapter covers if/elif/else, the three different ways to write a test condition, comparison and file-test operators, pattern matching, regex matching, and case.

The if Statement

if true; then
    echo "This always runs"
fi
This always runs

if runs whatever command follows it, and branches based on that command’s exit status: 0 (success) takes the then branch, anything else skips it. true and false here are actual Bash builtins that do nothing except produce those exit statuses on purpose — useful for demonstrations and placeholders, and worth knowing as real commands rather than keywords.

This matters because any command works in an if, not just comparison expressions:

if grep -q "root" /etc/passwd; then
    echo "Found a match"
fi
Found a match

grep -q searches silently and just sets its exit status based on whether it found a match — no comparison operator in sight, yet this is a completely ordinary, idiomatic if. The comparison syntax covered in the rest of this chapter is really just a convenient way to get a command (test, [, or [[) whose entire job is producing the right exit status for you to branch on.

elif And else

score=72

if [[ "$score" -ge 90 ]]; then
    echo "Grade: A"
elif [[ "$score" -ge 80 ]]; then
    echo "Grade: B"
elif [[ "$score" -ge 70 ]]; then
    echo "Grade: C"
else
    echo "Grade: F"
fi
Grade: C

Bash checks each condition top to bottom and runs the first branch whose condition succeeds — later elif branches are never even evaluated once one matches. else is optional and catches anything none of the preceding conditions matched.

test, [, and [[ — Three Ways To Write A Condition

Bash gives you three ways to write essentially the same comparison, and they’re not identical:

  • test EXPRESSION is an ordinary builtin command. It evaluates the expression and sets its exit status accordingly — nothing more.
  • [ EXPRESSION ] is test under a different name — [ is itself a real command (a builtin), and the closing ] is actually its final argument, not special syntax. Because it’s an ordinary command, its arguments go through the same word-splitting and globbing rules as any other command — which is exactly why unquoted variables inside [ ] are a real hazard, covered directly in this chapter’s Shell-Safety Considerations section.
  • [[ EXPRESSION ]] is a Bash keyword, not a command — Bash parses its contents specially, so unquoted variables inside it don’t undergo word splitting or globbing the way they do with [ or test. It also adds pattern matching and regex support that [ doesn’t have at all.

This course uses [[ ]] as the default for exactly these reasons — it’s safer by construction and strictly more capable, and since this is a Bash-specific course, there’s no portability reason to prefer the older [ form. You’ll still see [ constantly in other people’s scripts (it’s the only option in strictly POSIX-compatible shells), so it’s worth recognizing on sight even if you rarely write it yourself.

Note

Some UNIX purists will see [[ and immediately invoke “portability.” Sure, if portability is the requirement. But if Bash is installable, insisting on the bare minimum is just cargo culting. Install Bash. Just saying, no offence…

String Comparison Operators

a="apple"
b="banana"

if [[ "$a" == "$b" ]]; then
    echo "equal"
else 
    echo "not equal"
fi

if [[ "$a" != "$b" ]]; then
    echo "different"
else
    echo "same"
fi
not equal
different
OperatorMeaning
==Equal
!=Not equal
-z "$s"String is empty
-n "$s"String is non-empty
<, >Lexicographic comparison (inside [[ ]], no escaping needed — inside [ ], these need escaping or they’re interpreted as redirection instead)

-z and -n are worth calling out specifically — checking [[ -z "$value" ]] for “is this empty” reads more clearly, and is less error-prone, than comparing against a literal empty string.

Numeric Comparison Operators

String == and numeric equality are not the same thing"10" == "10.0" is false as a string comparison even though the numbers are equal. For numbers, use these dedicated operators instead:

OperatorMeaning
-eqEqual
-neNot equal
-ltLess than
-leLess than or equal
-gtGreater than
-geGreater than or equal
count=5

if [[ "$count" -ge 10 ]]; then
    echo "At or above threshold"
else
    echo "Below threshold"
fi
Below threshold

Note

Bash also has a separate arithmetic evaluation context, (( )), that supports the more familiar <, >, == for numbers directly — you’ll cover that alongside arithmetic expansion in general later in the course. For now, -eq/-lt/-gt and friends inside [[ ]] are the standard, reliable option.

File Test Operators

These check properties of a path on disk, without needing to open or read it:

OperatorMeaning
-ePath exists (any type)
-fExists and is a regular file
-dExists and is a directory
-rExists and is readable
-wExists and is writable
-xExists and is executable
-sExists and is non-empty
-LExists and is a symbolic link
if [[ -f "/etc/hosts" ]]; then
    echo "/etc/hosts is a regular file"
fi

if [[ ! -d "/nonexistent-directory" ]]; then
    echo "That directory doesn't exist"
fi
/etc/hosts is a regular file
That directory doesn't exist

! negates any condition inside [[ ]], as shown in the second example.

Pattern Matching With [[

[[ ]] supports shell glob-style pattern matching directly against == and !=, but only when the right-hand side is unquoted:

filename="report.txt"

if [[ "$filename" == *.txt ]]; then
    echo "It's a text file"
fi
It's a text file

The unquoted *.txt on the right is treated as a pattern — * matches any sequence of characters — rather than a literal string. Quoting that same right-hand side flips the meaning entirely:

if [[ "$filename" == "*.txt" ]]; then
    echo "This won't match unless the filename is literally the four characters *.txt"
else
    echo "No literal match"
fi
No literal match

This is easy to get backwards, since quoting is normally the safer default everywhere else in Bash — but here, quoting the pattern side specifically turns off pattern matching. The left-hand side (the value you’re testing) should still always be quoted, for the same word-splitting reasons covered since Chapter 2; it’s specifically the pattern on the right that you leave unquoted on purpose when you want glob matching.

Regular Expressions With =~

For more expressive matching than glob patterns allow, [[ ]] also supports extended regular expressions via =~:

version="v2.14.0"

if [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "Looks like a valid version string"
fi
Looks like a valid version string

Just like the pattern-matching case above, the regex on the right should be unquoted — quoting it forces a literal string comparison instead of treating it as a regex, in modern Bash versions.

Bash also captures matched groups automatically into a special array, BASH_REMATCH, immediately after a successful =~ match — the same special-array pattern you saw with PIPESTATUS in Chapter 5:

log_line="2026-08-23 ERROR: disk full"

if [[ "$log_line" =~ ^([0-9-]+)\ ([A-Z]+): ]]; then
    echo "Date: ${BASH_REMATCH[1]}"
    echo "Level: ${BASH_REMATCH[2]}"
fi
Date: 2026-08-23
Level: ERROR

${BASH_REMATCH[0]} holds the entire match, and ${BASH_REMATCH[1]}, ${BASH_REMATCH[2]}, and so on hold each parenthesized capture group in order — full array syntax is covered later in the course, but this indexed form is enough to use BASH_REMATCH productively right now.

case Statements

For matching one value against several possible patterns, case reads more clearly than a long if/elif chain:

fruit="banana"

case "$fruit" in
    apple)
        echo "It's an apple"
        ;;
    banana|plantain)
        echo "It's a banana or a plantain"
        ;;
    *)
        echo "Unknown fruit"
        ;;
esac
It's a banana or a plantain

Each pattern (or set of patterns joined with |, meaning “either of these”) ends its branch with ;;. Patterns support the same glob-style matching as [[ ]]’s unquoted right-hand side — *.txt, [0-9]*, and so on all work directly as case patterns. *) alone acts as a catch-all default, conventionally placed last.

Best Practices

  • Default to [[ ]] over [ ] or test in every script in this course, unless you have a specific, deliberate reason to write POSIX-portable syntax.
  • Always quote the value being tested, even inside [[ ]] where it’s technically safer — consistency here avoids having to remember which construct actually needs it.
  • Use -eq/-lt/-gt (and friends) for numbers, ==/!= for strings — never assume the two are interchangeable.
  • Reach for case once you’re writing more than two or three elif branches against the same value — it’s clearer to read and scales better to many patterns.
  • Leave the pattern side of ==/!=/=~ unquoted on purpose when you want glob or regex matching, and treat that as a deliberate exception to the “always quote” habit, not a contradiction of it.

Shell-Safety Considerations

The word-splitting hazard from Chapter 2 shows up directly in conditionals too — and it’s specifically a [ ] problem, not a [[ ]] one, which is one more reason this course defaults to [[ ]].

Naive (broken) version — an unquoted variable inside [ ], where the variable happens to be empty:

status=
if [ $status == "active" ]; then
    echo "Active"
else
    echo "Not active"
fi
bash: [: ==: unary operator expected

Because $status is empty and unquoted, [ $status == "active" ] collapses down to [ == "active" ] once word splitting removes the empty value entirely — leaving [ with no left-hand operand at all, which it can’t parse.

Fix 1 — quote the variable, which works with [ ] exactly the way it worked with touch back in Chapter 2:

status=
if [ "$status" == "active" ]; then
    echo "Active"
else
    echo "Not active"
fi
Not active

Fix 2 — use [[ ]] instead, which sidesteps the problem at the source, since it never applies word splitting to unquoted variables in the first place:

status=
if [[ $status == "active" ]]; then
    echo "Active"
else
    echo "Not active"
fi
Not active

Both fixes work. This course’s convention of defaulting to [[ ]] means you’d normally get Fix 2’s protection automatically — but the underlying lesson is the same one from Chapter 2: an unquoted variable’s value can vanish, split, or expand in ways that change what a command actually receives, and empty values are one of the most common ways that surfaces.

Hands-On: Building A Small Validation Script

1. Set up a working directory.

mkdir -p ~/shell-course/ch7
cd ~/shell-course/ch7

2. Reproduce the empty-variable [ ] bug, then confirm both fixes.

status=
if [ $status == "active" ]; then
    echo "Active"
else
    echo "Not active"
fi

Confirm the unary operator expected error, then rerun with [ "$status" == "active" ], and again with [[ $status == "active" ]], confirming both run cleanly.

3. Build a small numeric grading example.

cat > grade.sh << 'EOF'
#!/usr/bin/env bash

score="$1"

if [[ "$score" -ge 90 ]]; then
    echo "Grade: A"
elif [[ "$score" -ge 80 ]]; then
    echo "Grade: B"
elif [[ "$score" -ge 70 ]]; then
    echo "Grade: C"
else
    echo "Grade: F"
fi
EOF
chmod +x grade.sh

./grade.sh 85
./grade.sh 55
Grade: B
Grade: F

4. Test file operators against real paths.

mkdir demo-dir
touch demo-dir/notes.txt

if [[ -d demo-dir ]]; then
    echo "demo-dir is a directory"
fi

if [[ -f demo-dir/notes.txt ]]; then
    echo "notes.txt is a regular file"
fi

if [[ ! -e demo-dir/missing.txt ]]; then
    echo "missing.txt does not exist"
fi

5. Compare quoted vs. unquoted pattern matching.

filename="report.txt"

if [[ "$filename" == *.txt ]]; then
    echo "Unquoted pattern: matches"
fi

if [[ "$filename" == "*.txt" ]]; then
    echo "Quoted pattern: matches"
else
    echo "Quoted pattern: does not match"
fi

6. Extract fields from a string with =~ and BASH_REMATCH.

log_line="2026-08-23 ERROR: disk full"

if [[ "$log_line" =~ ^([0-9-]+)\ ([A-Z]+): ]]; then
    echo "Date: ${BASH_REMATCH[1]}"
    echo "Level: ${BASH_REMATCH[2]}"
fi

7. Write a case statement over the same fruit example.

for fruit in apple banana cherry; do
    case "$fruit" in
        apple)
            echo "$fruit: an apple"
            ;;
        banana|plantain)
            echo "$fruit: a banana or plantain"
            ;;
        *)
            echo "$fruit: unknown"
            ;;
    esac
done

Note

That last step uses a for loop to run the same case against three values — loops are covered fully in the next chapter, so don’t worry about the syntax yet; it’s here only to exercise case against more than one input without retyping it three times.

8. Clean up.

cd ~
rm -rf ~/shell-course/ch7

You’ve now written conditionals using the exit-status model directly, compared strings, numbers, and files, matched glob patterns and regular expressions, and seen exactly why [[ ]] is the safer default over [ ]. The next chapter covers loops — for, while, until, and the word-splitting pitfalls that show up constantly when looping over filenames.

Last updated on