Conditionals and Tests · about 35 min

case statements

Why this matters

When one value can take many forms, a chain of if/elif comparing the same variable over and over gets long and hard to read. case says "look at this value; here are the shapes it might have; do the thing for the first shape that fits". It is how scripts handle subcommands (start, stop, status), options (-v, --help), file types by extension, and yes/no answers. It is also portable: it works in every POSIX shell, unlike [[ ]].

The shape

case WORD in
    PATTERN1)
        COMMANDS
        ;;
    PATTERN2|PATTERN3)
        COMMANDS
        ;;
    *)
        COMMANDS
        ;;
esac

Patterns are globs

The patterns are the same glob patterns as in [[ == ]] and file names, matched against the whole word:

#!/bin/bash
# kind.sh: guess a file's kind from its name
for f in "$@"; do
    case $f in
        *.jpg|*.jpeg|*.png|*.gif) kind="image" ;;
        *.tar.gz|*.tgz)           kind="compressed tarball" ;;
        *.sh)                     kind="shell script" ;;
        .*)                       kind="hidden file" ;;
        *[0-9][0-9][0-9][0-9]*)   kind="has a number" ;;
        "")                       kind="empty name" ;;
        *)                        kind="unknown" ;;
    esac
    echo "$f: $kind"
done
$ bash kind.sh cat.png backup.tar.gz deploy.sh .profile report-2026 notes
cat.png: image
backup.tar.gz: compressed tarball
deploy.sh: shell script
.profile: hidden file
report-2026: has a number
notes: unknown

(for f in "$@" runs the body once per argument; module 10 covers it.) Order matters: put the specific patterns before the general ones. If .*) came first, .hidden.sh would be "hidden file", not "shell script"; you decide which you want by ordering.

To match a pattern character literally, quote it: "*") matches only a literal star, '?') only a question mark.

The classic use: subcommands and options

#!/bin/bash
# notes.sh: a tiny notes tool
notes=notes.txt
case $1 in
    add)
        shift
        echo "$*" >> "$notes"
        echo "noted" ;;
    list)
        cat "$notes" 2>/dev/null || echo "(no notes)" ;;
    count)
        wc -l < "$notes" 2>/dev/null || echo 0 ;;
    -h|--help|help)
        echo "usage: notes.sh add TEXT... | list | count" ;;
    *)
        echo "notes.sh: unknown command '$1' (try --help)" >&2
        exit 2 ;;
esac
$ bash notes.sh add buy milk
noted
$ bash notes.sh add call the bank
noted
$ bash notes.sh list
buy milk
call the bank
$ bash notes.sh count
2
$ bash notes.sh frobnicate; echo $?
notes.sh: unknown command 'frobnicate' (try --help)
2

Note the *) branch prints its error to standard error (>&2) and exits 2, the conventional status for "you used me wrong". Running with no argument lands there too, because an empty $1 does not match any of the named patterns.

Matching character classes

Bracket expressions work for single characters, which makes case a quick input classifier:

read -r -n 1 key     # read a single character
case $key in
    [0-9])        echo "digit" ;;
    [a-zA-Z])     echo "letter" ;;
    " ")          echo "space" ;;
    *)            echo "something else" ;;
esac

Note: ranges like [a-z] depend on the locale. The sandbox uses C.UTF-8, where [a-z] means just the lowercase ASCII letters. For locale-proof classes use [[:digit:]], [[:alpha:]], [[:space:]], [[:upper:]] and friends.

Fall-through: ;& and ;;&

bash adds two variants of ;; you will see occasionally:

case $level in
    debug)  echo "debug output on" ;&
    info)   echo "info output on" ;&
    warn)   echo "warnings on" ;;
esac

With level=debug all three lines print; with info, the last two. These are bash-only; in portable scripts stick to ;;.

case or if?

Use case when you compare one value against several patterns. Use if when the conditions are different kinds of test (a file exists, a number is big, a command succeeds). Under the hood case does no more than pattern matching: it never runs [, so it is also slightly faster, which only matters inside big loops.

Common mistakes

Exercises

yesno reads its input from standard input and must fail with status 1 for anything that is not a recognized answer, including an empty line. dispatch is a subcommand dispatcher; the checker runs it seven times in the same directory, in order, so the state file your script writes is there for the later runs.

Recap

Going further: read help case. Rewrite the age.sh example from lesson 1 of this module using case with patterns like [0-9]|1[0-2] and decide whether it is clearer or not.

Exercises

Exercise 1 · quiz

Which branch runs?

What does this print when f="notes.txt.bak"?

case $f in
    *.txt)  echo text ;;
    *.bak)  echo backup ;;
    *)      echo other ;;
esac
Answer
  • backup

Patterns must match the whole string. *.txt would need the name to end in .txt; notes.txt.bak ends in .bak, so the second pattern matches and prints backup. Only the first matching branch runs, and ;; ends it. If you want to match ".txt anywhere", the pattern is *.txt*.

Exercise 2 · write code

Understand an answer

Write a script that reads one line from standard input (use read -r answer) and prints:

Use a single case statement with | between alternatives.

File: script.sh

Starting point

#!/bin/bash
read -r answer

Example

input

y

expected output

yes

Example

input

YES

expected output

yes
Hint 1

One branch can list several patterns: y|Y|yes) ... ;;.

Hint 2

End with a catch-all *) branch that prints the message and runs exit 1.

Reference solution
#!/bin/bash
read -r answer
case $answer in
    y|Y|yes|Yes|YES) echo yes ;;
    n|N|no|No|NO)    echo no ;;
    *)  echo "please answer yes or no"
        exit 1 ;;
esac

Exercise 3 · write code

A tiny service controller

Many tools take a subcommand as their first argument (git commit, systemctl start). Write a script that does this for an imaginary service whose state is kept in a file named state in the current directory:

The checker runs the cases in order in the same directory, so the state file carries over.

File: script.sh

Starting point

#!/bin/bash
case $1 in
    start)
        ;;
esac

Example with arguments status

expected output

status: unknown

Example with arguments start

expected output

started
Hint 1

One branch per subcommand, plus *) for everything else, including an empty $1.

Hint 2

In status), use an if [[ -f state ]] inside the branch. $(cat state) inserts the file's contents.

Reference solution
#!/bin/bash
case $1 in
    start)
        echo running > state
        echo started ;;
    stop)
        echo stopped > state
        echo stopped ;;
    status)
        if [[ -f state ]]; then
            echo "status: $(cat state)"
        else
            echo "status: unknown"
        fi ;;
    *)
        echo "usage: script.sh start|stop|status"
        exit 2 ;;
esac