TechEarl

Bash if, else, and elif: Syntax, Test Operators, and Examples

Bash conditionals from the ground up: if/elif/else syntax, the [ ] vs [[ ]] vs (( )) test contexts, numeric and string and file operators, the case statement, and the unquoted-variable pitfall that breaks scripts on empty input.

Ishan KarunaratneIshan Karunaratne⏱️ 12 min readUpdated
Bash conditionals reference: if/elif/else syntax, [ ] vs [[ ]] vs (( )) test contexts, numeric, string, and file operators, the case statement, and the unquoted-variable pitfall.

Bash conditionals look simple until the first script breaks on an empty variable. The syntax is if CONDITION; then ...; elif OTHER; then ...; else ...; fi, but the condition itself runs in one of three test contexts ([ ], [[ ]], (( ))) and each behaves differently around quoting, word splitting, and operator precedence. Below is the full reference: every operator (numeric, string, file), the test-context comparison table, the case statement when elif chains get long, the short-circuit &&/|| shortcut and why it's subtly buggy, and the unquoted-variable pitfall that catches everyone exactly once.

How do I write an if-else in Bash?

A Bash if-else runs a block of commands when a test condition is true and a different block when it's false. The syntax is if CONDITION; then COMMANDS; else OTHER_COMMANDS; fi. Add elif CONDITION; then ... between if and else for additional branches. The condition can be any command (the exit status is the test), but most often you'll use one of three test contexts: [ EXPR ] (POSIX, max portability), [[ EXPR ]] (Bash builtin, richer string and pattern matching), or (( EXPR )) (arithmetic, no quoting needed). Numeric comparisons use -eq, -ne, -lt, -le, -gt, -ge inside [ ]; strings use =, !=, -z (empty), -n (non-empty); files use -f (regular file), -d (directory), -e (exists), -r/-w/-x (perms). Always quote variables in [ ] to avoid syntax errors on empty values. For known-list iteration, see Bash For Loops; for condition-driven repetition, see Bash While Loops.

Jump to:

The four most common conditional patterns

bash
# 1. Simple if-else
if [ -f /etc/hosts ]; then
    echo "File exists"
else
    echo "Missing"
fi

# 2. if-elif-else chain
if (( score >= 90 )); then
    echo "A"
elif (( score >= 80 )); then
    echo "B"
else
    echo "C or below"
fi

# 3. Test string equality with [[ ]]
if [[ "$env" == "prod" ]]; then
    echo "Production deploy"
fi

# 4. Negate a test with !
if ! command -v jq > /dev/null; then
    echo "jq is not installed" >&2
    exit 1
fi

These four shapes cover ~90% of real-world conditionals. The rest of this page covers the operators, the test-context choice, and the pitfalls that turn working scripts into scripts that misbehave on edge cases.

Test contexts: [ ] vs [[ ]] vs (( ))

Bash gives you three test contexts. Knowing which to use when is the difference between robust conditionals and scripts that break on the first empty variable.

ContextUse caseExample
[ ] (POSIX test)Maximum portability; works in sh as well as Bashif [ "$x" -lt 10 ]; then ...
[[ ]] (Bash builtin)Rich string ops, glob and regex matching, no word splittingif [[ "$file" == *.log ]]; then ...
(( )) (arithmetic)Numeric comparison, no quoting, C-style operatorsif (( x < 10 )); then ...

Practical guidance:

  • Numeric: prefer (( )). No quoting, supports ++, --, +=, -=, <=, >=, <, > directly.
  • String equality, pattern, regex: prefer [[ ]]. Supports ==, !=, =~ (regex), <, > (lexicographic), and globs on the right side.
  • POSIX sh compatibility required: stick to [ ] and quote every variable.

The single most common bug is using [ ] with an unquoted variable that happens to be empty: [ $x -lt 10 ] becomes [ -lt 10 ] and Bash errors out. [[ ]] and (( )) don't have this problem.

Numeric operators

Inside [ ] (or [[ ]] when you want POSIX-style), use the dash-prefixed operators:

OperatorMeaningExample
-eqequal[ "$x" -eq 10 ]
-nenot equal[ "$x" -ne 0 ]
-ltless than[ "$x" -lt 100 ]
-leless than or equal[ "$x" -le 100 ]
-gtgreater than[ "$x" -gt 0 ]
-gegreater than or equal[ "$x" -ge 18 ]

Inside (( )), use C-style operators without quoting:

bash
if (( count == 0 )); then
    echo "Empty"
fi

if (( score >= 80 && score < 90 )); then
    echo "B grade"
fi

# Arithmetic AND a side-effect increment in one expression
if (( attempts++ < 3 )); then
    echo "Try again, attempt $attempts"
fi

(( )) returns exit status 0 if the result is non-zero (truthy in arithmetic) and 1 if the result is zero (falsy). That's the opposite of how it reads, but it matches C semantics.

String operators

OperatorMeaningExample
= or ==equal[ "$x" = "yes" ]
!=not equal[[ "$x" != "no" ]]
-zempty (zero length)[ -z "$x" ]
-nnon-empty[ -n "$x" ]
<lexicographically less[[ "$a" < "$b" ]]
>lexicographically greater[[ "$a" > "$b" ]]
=~regex match[[ "$x" =~ ^[0-9]+$ ]]

Two things to know:

  • Inside [ ], use = (POSIX) rather than == (Bash-only). Inside [[ ]], both work.
  • < and > are redirection operators in [ ]. They only do string comparison inside [[ ]].

A common idiom: check if a variable is set AND non-empty before using it.

bash
if [ -z "$DEPLOY_TOKEN" ]; then
    echo "DEPLOY_TOKEN is not set" >&2
    exit 1
fi

Regex matching (Bash 4+) is one of the strongest reasons to use [[ ]]:

bash
if [[ "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
    echo "Looks like an email"
fi

The regex goes on the right side and must NOT be quoted — quotes turn it into a literal string match.

File operators

The full file-test set is rich. The ones you'll actually use:

OperatorTrue when...
-f FILEFILE exists and is a regular file
-d FILEFILE exists and is a directory
-e FILEFILE exists (any type)
-r FILEFILE is readable by the current user
-w FILEFILE is writable by the current user
-x FILEFILE is executable by the current user
-s FILEFILE exists and is non-empty
-L FILEFILE is a symbolic link

Examples:

bash
# Guard before sourcing config
if [ -f "$HOME/.deployrc" ]; then
    source "$HOME/.deployrc"
fi

# Bail out if the backup directory doesn't exist
if [ ! -d "$BACKUP_DIR" ]; then
    echo "Backup dir missing: $BACKUP_DIR" >&2
    exit 1
fi

# Skip processing zero-byte log files
for log in *.log; do
    [ -s "$log" ] || continue
    process "$log"
done

Same pattern shows up in the How to Export All MySQL Databases backup script (file-size sanity check before rotating), and in the How to Increase Google Cloud VM Disk Size Without Rebooting walkthrough (waiting for the device file to appear).

Combining tests with &&, ||, and !

The modern way to combine tests is with && (logical AND) and || (logical OR) BETWEEN brackets, not inside them.

bash
# Both must be true
if [ -f config.yml ] && [ -r config.yml ]; then
    echo "Config exists and is readable"
fi

# Either is enough
if [ -z "$VAR1" ] || [ -z "$VAR2" ]; then
    echo "At least one var is empty"
fi

# Negation
if ! [ -d /var/log/myapp ]; then
    mkdir -p /var/log/myapp
fi

Inside [[ ]], you can use && and || directly — no need to split the brackets:

bash
if [[ -f config.yml && -r config.yml ]]; then
    echo "Config exists and is readable"
fi

The legacy -a (AND) and -o (OR) inside a single [ ] are deprecated:

bash
# Don't write this — POSIX itself flags -a/-o as obsolete
if [ "$x" -eq 1 -a "$y" -eq 2 ]; then ...

# Write this instead
if [ "$x" -eq 1 ] && [ "$y" -eq 2 ]; then ...

shellcheck will flag the deprecated form. Stick to the split-bracket idiom.

elif chains and the case alternative

Three branches is fine for elif. Five branches is a smell. Beyond that, case reads better.

bash
# elif chain — works but gets noisy fast
if [ "$env" = "dev" ]; then
    echo "Development"
elif [ "$env" = "staging" ]; then
    echo "Staging"
elif [ "$env" = "prod" ]; then
    echo "Production"
else
    echo "Unknown env: $env" >&2
    exit 1
fi

# case — the right tool for multi-branch on one variable
case "$env" in
    dev)     echo "Development" ;;
    staging) echo "Staging" ;;
    prod)    echo "Production" ;;
    *)       echo "Unknown env: $env" >&2; exit 1 ;;
esac

case matches glob patterns, not just literal strings:

bash
case "$file" in
    *.log|*.txt)  echo "Text-ish: $file" ;;
    *.gz|*.tar)   echo "Archive: $file" ;;
    *)            echo "Other: $file" ;;
esac

The ;; terminates each branch. *) is the default case (matches anything). Pipe | joins patterns within one branch. For a deep dive on the comparable iteration pattern, see Bash Arrays and how case pairs naturally with array dispatch.

The &&/|| ternary shortcut and its trap

Bash supports a one-line "if-then-else" via && and ||:

bash
# Looks like a ternary: condition && then-branch || else-branch
[ -f config.yml ] && echo "exists" || echo "missing"

This works in most cases, but it has a subtle bug: if the then-branch itself fails (returns non-zero), the else-branch ALSO runs.

bash
# BAD — if echo "exists" somehow fails (e.g., disk full), you get BOTH messages
[ -f config.yml ] && echo "exists" || echo "missing"

For two-message-or-one logic that you actually trust, use a real if:

bash
if [ -f config.yml ]; then
    echo "exists"
else
    echo "missing"
fi

The short-circuit form is fine when the then-branch is a simple variable assignment or a guaranteed-zero-exit command (true, :):

bash
# Safe — assignment never fails
[ -f config.yml ] && config_exists=1 || config_exists=0

# Safe — short-circuit guards a side effect
mkdir -p /tmp/work || { echo "Cannot create work dir" >&2; exit 1; }

The rule of thumb: use &&/|| for guard clauses, use if/else for branching logic.

Common pitfalls

1. Unquoted variables in [ ]. [ $x -lt 10 ] becomes [ -lt 10 ] and errors out when $x is empty. Always quote: [ "$x" -lt 10 ]. Better: switch to [[ ]] or (( )) which don't have this problem.

2. Using == in [ ]. POSIX test only defines =. [ "$x" == "y" ] works in Bash but breaks in dash/sh. Use = in [ ]; use == only in [[ ]].

3. Mixing numeric and string operators. [ "$x" -lt "10" ] works; [ "$x" < "10" ] is a STRING comparison (and also a redirect attempt). Use -lt in [ ] and < only inside (( )) or [[ ]].

4. Quoting the regex in [[ =~ ]]. [[ "$x" =~ "^[0-9]+$" ]] matches the literal string ^[0-9]+$, not a regex. The regex on the right side of =~ must be unquoted.

5. Forgetting then or fi. Bash gives confusing "syntax error near unexpected token" messages for missing then or fi. Trace the indent.

6. else if instead of elif. Bash uses elif. else if is two separate constructs and creates an unterminated outer if.

7. The &&/|| ternary trap. [ X ] && A || B runs B if A fails. Use if/else for two-message logic.

8. Comparing numbers with the wrong context. if [ "$count" > 10 ] is a redirection, not a comparison. Use [ "$count" -gt 10 ] or (( count > 10 )).

9. Negation order with !. ! [ -f file ] works; [ ! -f file ] also works. Both are fine; pick one and stay consistent.

Debugging Bash conditionals

bash
# Trace every command and its arguments
bash -x script.sh

# Trace one section
set -x
if [ -f config.yml ]; then
    source config.yml
fi
set +x

# Static analysis — catches every pitfall above
shellcheck script.sh

shellcheck flags unquoted variables, deprecated -a/-o, == in [ ], missing quotes around regex, and most other conditional bugs. Install with brew install shellcheck or apt install shellcheck.

For the loop counterparts that share the same test-context rules, see Bash For Loops and Bash While Loops. For passing branching decisions into reusable code, see Bash Functions.

What to do next

FAQ

TagsBashShell ScriptingLinuxif elsetestconditionals
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Bash for loop reference: brace-range {1..10}, sequence (seq), array, glob, C-style, nested, parallel with xargs. Plus safe file iteration with find -print0, globbing pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ differences.

Bash For Loops: Syntax, Examples, and One-Liners

Every form of the Bash for loop with working examples: brace-range, sequence-expression, array, glob, C-style, nested, and parallel. Plus the safe file-iteration patterns, common pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ gotchas.