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 by then.
  • then — introduces the commands to run when the preceding condition’s exit status was 0. Can go on the same line as if if 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 per if; 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_dir to /home using ${1:-}, which expands to the first argument or an empty string if none was given.
  • The first if runs [[ -z "$target_dir" ]]. Since target_dir is not empty, this test exits with status 1 (false), so the then block is skipped.
  • Bash checks the elif: [[ ! -d "$target_dir" ]]. /home is a directory, so -d is true, ! flips it to false, and this block is skipped too. Since there’s no matching branch and no else, the whole if/elif construct 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 in usage.
  • The second if runs (( usage >= 90 )). Suppose usage is 74; this arithmetic test is false, so Bash checks the elif: (( usage >= 70 )), which is true. That branch’s echo runs, and the script’s exit status becomes whatever that last command returned — 0, since echo succeeded.

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/-lt when 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 else whenever 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 pipefail so unexpected failures elsewhere don’t get silently ignored.

Practice Exercises

  • Write a script that takes a path as $1 and, using if/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 EDITOR is set (hint: -z/-n on "$EDITOR"); if it isn’t, print a message and fall back to a default value such as nano.
  • Extend the disk-usage script from Example 3 with a fourth tier: a critical message when usage is 95% or higher, checked before the existing 90% warning branch.

Summary

  • if works by running a command and checking its exit status — 0 means the then block runs, non-zero means Bash moves on to the next elif or else.
  • [ and [[ ]] are commands/keywords whose whole job is to evaluate an expression and exit 0 or 1; [[ ]] 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.
  • elif chains run top to bottom and stop at the first true condition; else only runs if nothing above it matched; fi always closes the block.