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
WORDis expanded once (usually$1or a variable). It is not word-split, so quotes around it are optional.- Each branch starts with one or more patterns separated by
|, then). - The commands run until
;;, which ends the branch. - Patterns are tried from top to bottom; only the first match runs.
*)matches anything, so it serves as the default branch. Put it last.esac(case backwards) closes the statement.- If nothing matches, nothing runs and the status is 0.
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 usesC.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:
;&runs the next branch's commands too, without testing its pattern (fall through).;;&goes on to test the remaining patterns, running every further branch that matches.
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
- Missing
;;. bash reportssyntax error near unexpected token ')'at the next pattern. - Forgetting
esac.syntax error: unexpected end of file. - Default branch first. A
*)at the top catches everything and the other branches never run. - Expecting regular expressions.
caseuses glob patterns:*.txt, not.*\.txt$. And+,{2}and friends mean nothing special. - Partial matches.
start)does not matchstarted; patterns must match the whole word. Usestart*)if you want the prefix. - Spaces inside alternatives.
y | yes)works because bash ignores the spaces around|, but"y | yes")is one literal pattern containing spaces.
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
case WORD in PATTERN) ...;; esacruns the first branch whose glob pattern matches the whole word.|separates alternative patterns;*)is the default branch and goes last.- Order branches from specific to general.
- Typical uses: subcommands, options, file extensions, yes/no answers.
;∧;&change the "first match only" behavior in bash;;;is the portable one.
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.