Conditionals: if, elif, else
A conditional lets a Bash script make decisions: run one block of commands if something is true, a different block if it isn’t, and as many blocks in between as you need. The if, elif, and else keywords are how Bash implements this. They don’t test values in some abstract sense — they test the exit status of a command, the same mechanism the shell uses for everything else. Understanding that one fact is the key to writing conditionals that behave the way you expect instead of surprising you.
Overview: How if Statements Really Work
Bash has no built-in boolean type. Every command that finishes running returns a small integer called an exit status (0–255), stored automatically in the special variable $?. By convention — not by any rule the kernel enforces — 0 means "success" and any non-zero value means "failure," usually with different non-zero numbers meaning different kinds of failure.
The if keyword does not evaluate an expression the way a calculator would. It runs a command and checks whether that command exited with status 0. If it did, the then block runs. If it didn’t, Bash moves on to check the next elif (if any), and finally falls back to else if nothing matched. This means you can put literally any command after if, not just a comparison:
if grep -q "ERROR" /var/log/app.log; then echo "Errors found"; fi
Here grep -q exits 0 if it found a match and 1 if it didn’t — if is just reading that exit status, no special comparison syntax involved.
Most of the time you want to test values (is this string equal to that one, is this number bigger, does this file exist), so Bash gives you a command whose entire job is to evaluate an expression and exit 0 or 1 based on the result. That command is test, more commonly written using its bracket alias [ ... ]. [ is a real command (a shell builtin, and also present as /usr/bin/[ on disk) — which is exactly why it needs a space after it and before the closing ]: the shell has to see [, its arguments, and ] as separate words, the same as any other command and its arguments.
Bash also provides [[ ... ]], a keyword built into the parser rather than an external command. It behaves like an upgraded test: unquoted variables inside it don’t undergo word-splitting or globbing, it supports &&, ||, and pattern matching directly, and it can’t be accidentally confused with a real file named [. For Bash-specific scripts (as opposed to portable /bin/sh scripts), [[ ]] is the safer default and is what this lesson uses.
An if block can chain as many elif ("else if") clauses as you want. Bash evaluates them top to bottom and runs the first block whose condition succeeds — it does not keep checking after a match. else is the catch-all that runs only if nothing above it matched. The block always closes with fi, which is if spelled backwards — the same pattern Bash uses for every compound command (done closes loops, esac closes case).
Syntax
if [[ condition1 ]]; then
commands
elif [[ condition2 ]]; then
commands
else
commands
fi
if— starts the block; followed by a command (often a[[ ]]or[ ]test) and required bythen.then— introduces the commands to run when the preceding condition’s exit status was0. Can go on the same line asifif separated by a semicolon, or on its own line with no semicolon.elif— optional, repeatable; checked only if every condition above it failed.else— optional, at most one perif; runs when nothing above matched.fi— required, closes the block.
Inside a test, the operator you need depends on what you’re comparing:
| Category | Operator | Meaning |
|---|---|---|
| String | = or == |
strings are equal |
| String | != |
strings are not equal |
| String | -z |
string is empty |
| String | -n |
string is not empty |
| Numeric | -eq |
numbers are equal |
| Numeric | -ne |
numbers are not equal |
| Numeric | -gt / -ge |
greater than / greater than or equal |
| Numeric | -lt / -le |
less than / less than or equal |
| File | -f |
path exists and is a regular file |
| File | -d |
path exists and is a directory |
| File | -e |
path exists (any type) |
| File | -r / -w / -x |
path is readable / writable / executable |
For arithmetic comparisons, Bash also offers (( )), which lets you write ordinary math syntax like (( score >= 90 )) instead of [[ score -ge 90 ]]. Both are correct; (( )) is usually more readable for numeric logic.
Examples
Example 1: Back up a log file only if it exists
#!/usr/bin/env bash
file="/var/log/app.log"
if [[ -f "$file" ]]; then
echo "Found $file, creating backup..."
cp "$file" "$file.bak"
else
echo "Error: $file does not exist." >&2
exit 1
fi
Output:
Found /var/log/app.log, creating backup...
The -f test checks that /var/log/app.log exists and is a regular file. Because it’s quoted as "$file", the check is safe even if the path ever contained a space. If the file were missing, the else branch would print an error to standard error (>&2) and exit with status 1, signaling failure to whatever called the script.
Example 2: elif chain to assign a letter grade
#!/usr/bin/env bash
score=82
if (( score >= 90 )); then
grade="A"
elif (( score >= 80 )); then
grade="B"
elif (( score >= 70 )); then
grade="C"
else
grade="F"
fi
echo "Score: $score -> Grade: $grade"
Output:
Score: 82 -> Grade: B
Bash checks score >= 90 first (false, since 82 is less than 90), then score >= 80 (true), assigns grade="B", and skips every remaining elif and the else — it never even evaluates the >= 70 check once a match is found.
Example 3: Validate arguments and check disk usage
#!/usr/bin/env bash
set -euo pipefail
target_dir="${1:-}"
if [[ -z "$target_dir" ]]; then
echo "Usage: $0 " >&2
exit 1
elif [[ ! -d "$target_dir" ]]; then
echo "Error: $target_dir is not a directory." >&2
exit 1
fi
usage=$(df --output=pcent "$target_dir" | tail -n 1 | tr -d ' %')
if (( usage >= 90 )); then
echo "Warning: $target_dir is at ${usage}% capacity."
elif (( usage >= 70 )); then
echo "Notice: $target_dir is at ${usage}% capacity."
else
echo "OK: $target_dir is at ${usage}% capacity."
fi
Output (run as ./check_disk.sh /home):
Notice: /home is at 74% capacity.
This script combines everything: -z checks whether an argument was supplied at all, ! -d checks the negation of "is a directory" (the ! inverts the test), and the final (( )) chain picks a message based on the numeric usage percentage parsed out of df‘s output. Because it starts with set -euo pipefail, any unexpected failure elsewhere in the script (like df not existing) stops execution immediately instead of continuing with bad data.
How It Works Step by Step
Walking through Example 3 when called as ./check_disk.sh /home:
- Bash sets
target_dirto/homeusing${1:-}, which expands to the first argument or an empty string if none was given. - The first
ifruns[[ -z "$target_dir" ]]. Sincetarget_diris not empty, this test exits with status1(false), so thethenblock is skipped. - Bash checks the
elif:[[ ! -d "$target_dir" ]]./homeis a directory, so-dis true,!flips it to false, and this block is skipped too. Since there’s no matching branch and noelse, the wholeif/elifconstruct simply does nothing and execution continues on the next line. df --output=pcent /home | tail -n 1 | tr -d ' %'runs in a subshell (that’s what$( )does — it forks a child process, runs the command, and captures whatever it writes to standard output), and the result is stored inusage.- The second
ifruns(( usage >= 90 )). Supposeusageis74; this arithmetic test is false, so Bash checks theelif:(( usage >= 70 )), which is true. That branch’sechoruns, and the script’s exit status becomes whatever that last command returned —0, sinceechosucceeded.
Common Mistakes
Mistake 1: Missing spaces around the brackets
count=5
if [$count -gt 0]; then
echo "There are items."
fi
This fails because [ is a command, and the shell needs whitespace to tell where the command name ends and its arguments begin. [$count with no space is parsed as a single word — a command literally named [5 — which doesn’t exist, producing a "command not found" error instead of running your comparison.
count=5
if [ "$count" -gt 0 ]; then
echo "There are items."
fi
Output:
There are items.
Mistake 2: Leaving a variable unquoted inside a test
username=
if [ $username = "admin" ]; then
echo "Welcome, admin"
fi
When username is empty and unquoted, [ $username = "admin" ] expands to [ = "admin" ] — the left-hand operand disappears entirely, and test sees = where it expected a value, producing an error:
script.sh: line 3: [: =: unary operator expected
Quoting the variable guarantees it always expands to exactly one argument, even when empty:
username=
if [ "$username" = "admin" ]; then
echo "Welcome, admin"
else
echo "Access denied"
fi
Output:
Access denied
Mistake 3: Using a numeric operator to compare strings
status="failed"
if [ "$status" -eq "success" ]; then
echo "It worked!"
fi
-eq tells test to convert both sides to integers before comparing. Neither "failed" nor "success" is a number, so Bash reports:
script.sh: line 3: [: failed: integer expression expected
Use = (or == inside [[ ]]) for strings, and reserve -eq/-ne/-gt/etc. for numbers:
status="failed"
if [ "$status" = "success" ]; then
echo "It worked!"
else
echo "It failed."
fi
Output:
It failed.
Best Practices
- Prefer
[[ ]]over[ ]in Bash-specific scripts — it avoids word-splitting surprises and supports pattern matching and&&/||directly. - Always quote variable expansions inside a test (
"$var"), even when you’re confident the value won’t contain spaces — an empty or unset variable can break an unquoted test the same way a value with spaces can. - Use
(( ))for arithmetic comparisons instead of-gt/-ltwhen the logic is purely numeric; it reads closer to ordinary math. - Never mix string operators (
=,!=) with numeric ones (-eq,-ne) — pick the operator that matches the data type you’re actually comparing. - Check
$?immediately after the command you care about, before running anything else — every new command overwrites it. - Include an explicit
elsewhenever the "no condition matched" case needs its own handling (an error message, a default value, a non-zero exit) rather than silently falling through. - For scripts with real error-handling requirements, pair conditionals with
set -euo pipefailso unexpected failures elsewhere don’t get silently ignored.
Practice Exercises
- Write a script that takes a path as
$1and, usingif/elif/else, prints whether it’s a regular file, a directory, or doesn’t exist at all. - Write a script that checks whether the environment variable
EDITORis set (hint:-z/-non"$EDITOR"); if it isn’t, print a message and fall back to a default value such asnano. - Extend the disk-usage script from Example 3 with a fourth tier: a
criticalmessage when usage is 95% or higher, checked before the existing 90% warning branch.
Summary
ifworks by running a command and checking its exit status —0means thethenblock runs, non-zero means Bash moves on to the nexteliforelse.[and[[ ]]are commands/keywords whose whole job is to evaluate an expression and exit0or1;[[ ]]is the safer, Bash-specific choice.- Use string operators (
=,!=,-z,-n) for text, numeric operators (-eq,-gt, etc. or(( ))) for numbers, and file-test operators (-f,-d,-e) for paths — mixing them causes errors. - Always quote variables inside test expressions to avoid word-splitting and "unary operator expected" errors on empty values.
elifchains run top to bottom and stop at the first true condition;elseonly runs if nothing above it matched;fialways closes the block.
