Back to Blog

Zsh Conditionals: if/else, File Tests & case Statements | Shell Scripting Tutorial #9

Sandy LaneSandy Lane

Video: Zsh Conditionals: if/else, File Tests & case Statements | Shell Scripting Tutorial #9 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 9: Conditionals — if, [[ ]], File Tests, case

if [[ condition ]]; then ... fi. String tests ==, !=, -z (empty), -n (non-empty). Numeric -eq -ne -lt -le -gt -ge. File tests -e exists, -f file, -d dir, -r readable. Logical && ||. case ... esac for multi-way switch.

Conditionals make scripts decide. The Zsh syntax is verbose but readable once you know the operators.

if/then/fi

if [[ condition ]]; then
  # commands
elif [[ other_condition ]]; then
  # commands
else
  # commands
fi

fi ends the block (if backwards). then always on the same line as if/elif after a ;, or on the next line.

if [[ -e file.txt ]]; then
  echo "exists"
fi

String tests

name="Alice"
empty=""

[[ -n "$name" ]]      # non-empty
[[ -z "$empty" ]]     # zero length (empty)
[[ "$name" == "Alice" ]]   # equal
[[ "$name" != "Bob" ]]     # not equal

# Pattern match (glob)
[[ "report.txt" == *.txt ]]   # true

# Regex match (Zsh extension)
[[ "abc123" =~ ^[a-z]+[0-9]+$ ]]   # true; groups in ${match[1]}

Always quote variables in tests. Without quotes, an empty variable becomes nothing and changes the syntax:

# UNSAFE
if [[ $x == hello ]]; then ...   # if $x is empty, becomes "[[ == hello ]]"

# SAFE
if [[ "$x" == hello ]]; then ...

Inside [[ ]], Zsh is more forgiving than Bash, but quote anyway for portability.

Numeric tests

age=25
limit=18

[[ $age -gt $limit ]]    # greater than
[[ $age -ge $limit ]]    # greater or equal
[[ $age -lt $limit ]]    # less than
[[ $age -le $limit ]]    # less or equal
[[ $age -eq $limit ]]    # equal
[[ $age -ne $limit ]]    # not equal

Mnemonic: gt, ge, lt, le, eq, ne — like Perl/Fortran.

For numeric comparison, you can also use (( )):

if (( age > limit )); then
  echo "older"
fi

if (( count == 0 )); then
  echo "empty"
fi

(( )) uses C-style operators (>, <, ==, !=, &&, ||). Cleaner for math-heavy logic.

File tests

target="${1:-/etc/hosts}"

[[ -e "$target" ]]    # exists (file or directory)
[[ -f "$target" ]]    # regular file
[[ -d "$target" ]]    # directory
[[ -L "$target" ]]    # symlink
[[ -r "$target" ]]    # readable
[[ -w "$target" ]]    # writable
[[ -x "$target" ]]    # executable
[[ -s "$target" ]]    # non-empty (size > 0)
[[ "$file1" -nt "$file2" ]]   # newer than
[[ "$file1" -ot "$file2" ]]   # older than

Negate with !:

[[ ! -e "$path" ]]    # doesn't exist
[[ ! -d "$path" ]]    # not a directory

Logical operators: && and ||

Inside [[ ]]:

if [[ -f "$file" && -r "$file" ]]; then
  echo "readable file"
fi

if [[ -f "$path" || -d "$path" ]]; then
  echo "exists"
fi

Outside [[ ]], && and || chain commands by exit status:

mkdir -p backup && cp -a src/ backup/
# (only copy if mkdir succeeded)

[[ -f config.yaml ]] || { echo "missing config" >&2; exit 1; }
# (error if config absent)

A grade checker

echo -n "Enter your score (0-100): "
read score

if [[ $score -ge 90 ]]; then
  echo "Grade: A - Excellent!"
elif [[ $score -ge 80 ]]; then
  echo "Grade: B - Good job!"
elif [[ $score -ge 70 ]]; then
  echo "Grade: C - Not bad."
elif [[ $score -ge 60 ]]; then
  echo "Grade: D - Needs work."
else
  echo "Grade: F - Try again."
fi

elif chains check top-to-bottom; the first matching branch runs.

case: multi-way switch

For multi-way pattern matching, case is cleaner than nested if/elif:

case "$1" in
  start)
    echo "Starting..."
    ;;
  stop)
    echo "Stopping..."
    ;;
  restart|reload)        # multiple patterns separated by |
    echo "Restarting..."
    ;;
  status)
    echo "Status..."
    ;;
  *)
    echo "Usage: $0 {start|stop|restart|status}"
    exit 1
    ;;
esac

Each pattern uses ) to start its block, ;; to end. Patterns can be globs (*.txt, ???) or alternations (a|b|c). The *) at the end is the catch-all.

read -p "Color: " color
case "$color" in
  red|orange|yellow) echo "warm" ;;
  blue|green|purple) echo "cool" ;;
  *) echo "neutral" ;;
esac

[[ ]] vs [ ] vs test

Three forms exist:

  • [[ condition ]] — Zsh/Bash extension. Modern. Use this.
  • [ condition ] — POSIX. Older. = for string equality.
  • test condition — same as [ ], just a different syntax.

[[ ]] is preferred:

  • No word splitting on unquoted variables.
  • Pattern matching with == and !=.
  • =~ for regex.
  • &&/|| inside the brackets.
  • Cleaner overall.

For maximum portability (e.g., /bin/sh), use [ ]. Otherwise, always [[ ]].

Combining tests

# Check that a path exists, is a directory, and is readable
if [[ -d "$dir" && -r "$dir" ]]; then
  process "$dir"
fi

# Multiple OR
if [[ "$mode" == "dev" || "$mode" == "test" ]]; then
  echo "non-prod"
fi

# Negation
if [[ ! -f "$config" ]]; then
  echo "config missing" >&2
  exit 1
fi

Empty/unset distinction

unset x
y=""

[[ -z "${x:-}" ]]    # true — x is unset
[[ -z "$y" ]]        # true — y is empty

[[ -v x ]]           # false — x is unset
[[ -v y ]]           # true — y is set (even if empty)

-v distinguishes "set" from "non-empty." -z only checks length.

A real-world script

#!/usr/bin/env zsh
set -euo pipefail

CONFIG="${CONFIG:-./config.yaml}"

# Validate config exists and is readable
if [[ ! -f "$CONFIG" ]]; then
  echo "Error: $CONFIG not found" >&2
  exit 1
fi

if [[ ! -r "$CONFIG" ]]; then
  echo "Error: $CONFIG not readable" >&2
  exit 1
fi

# Determine action from first arg
ACTION="${1:-status}"

case "$ACTION" in
  start)
    [[ -f /tmp/myapp.pid ]] && { echo "Already running" >&2; exit 2; }
    echo "Starting..."
    ;;
  stop)
    [[ -f /tmp/myapp.pid ]] || { echo "Not running" >&2; exit 2; }
    echo "Stopping..."
    rm /tmp/myapp.pid
    ;;
  status)
    if [[ -f /tmp/myapp.pid ]]; then
      echo "Running"
    else
      echo "Stopped"
    fi
    ;;
  *)
    echo "Usage: $0 {start|stop|status}" >&2
    exit 1
    ;;
esac

Standard service-script shape: validate prereqs, dispatch on action.

Common stumbles

if [ $var = "value" ] with empty $var becomes [ = "value" ] — error. Always quote: [ "$var" = "value" ]. Or use [[ ]] (no word splitting).

== vs =. [[ ]] accepts both for string equality. [ ] requires =. Stick with == for [[ ]].

Numeric in [[ ]]. [[ "5" -gt "3" ]] works — [[ ]] does numeric comparison via -gt etc. But [[ "5" > "3" ]] does string comparison. For numbers, use -gt or (( )).

then placement. if [[ x ]] then errors. Need a ; or newline: if [[ x ]]; then.

Forgot fi. "syntax error: unexpected end of file." Every if needs a closing fi.

Glob in [[ ]] vs literal. [[ "$name" == *.txt ]] is a glob. To match a literal *, quote the right side: [[ "$name" == "*.txt" ]].

Regex with quotes. [[ "abc" =~ "[a-z]" ]] — the regex is quoted, so it's literal. Don't quote regex: [[ "abc" =~ [a-z] ]].

case patterns matching too greedily. case $x in 1) ... ;; *) ... ;;* matches everything including the empty string. Order patterns from specific to general.

What's next

Lesson 10: loops. for, while, until, file processing.

Recap

if [[ cond ]]; then ... elif ... else ... fi. String: ==, !=, -z (empty), -n (non-empty). Numeric: -eq -ne -lt -le -gt -ge or (( )) with <>==. File: -e -f -d -L -r -w -x -s. Logical && || inside [[ ]] and outside (chains by exit code). case ... esac for switching on patterns. Always quote variables. Use [[ ]] over [ ].

Next lesson: loops.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.