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"
fiThis always runsif 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"
fiFound a matchgrep -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"
fiGrade: CBash 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 EXPRESSIONis an ordinary builtin command. It evaluates the expression and sets its exit status accordingly — nothing more.[ EXPRESSION ]istestunder 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[ortest. 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"
finot equal
different| Operator | Meaning |
|---|---|
== | 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:
| Operator | Meaning |
|---|---|
-eq | Equal |
-ne | Not equal |
-lt | Less than |
-le | Less than or equal |
-gt | Greater than |
-ge | Greater than or equal |
count=5
if [[ "$count" -ge 10 ]]; then
echo "At or above threshold"
else
echo "Below threshold"
fiBelow thresholdNote
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:
| Operator | Meaning |
|---|---|
-e | Path exists (any type) |
-f | Exists and is a regular file |
-d | Exists and is a directory |
-r | Exists and is readable |
-w | Exists and is writable |
-x | Exists and is executable |
-s | Exists and is non-empty |
-L | Exists 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"
fiIt's a text fileThe 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"
fiNo literal matchThis 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"
fiLooks like a valid version stringJust 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]}"
fiDate: 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"
;;
esacIt's a banana or a plantainEach 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[ ]ortestin 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
caseonce you’re writing more than two or threeelifbranches 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"
fibash: [: ==: unary operator expectedBecause $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"
fiNot activeFix 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"
fiNot activeBoth 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/ch72. Reproduce the empty-variable [ ] bug, then confirm both fixes.
status=
if [ $status == "active" ]; then
echo "Active"
else
echo "Not active"
fiConfirm 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 55Grade: B
Grade: F4. 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"
fi5. 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"
fi6. 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]}"
fi7. 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
doneNote
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/ch7You’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.