case Statements
A case statement is Bash’s tool for matching a single value against a list of patterns and running different commands depending on which pattern matches. It replaces long chains of if/elif/elif comparisons with something shorter, more readable, and purpose-built for exactly this job: testing one word against many possibilities. If you have ever written a script that reacts differently to start, stop, or restart arguments, or that needs to branch on a file extension, case is the right tool.
Overview / How case Statements Work
A case statement takes a single word (usually a variable expansion like \"$1\" or \"$answer\") and compares it, top to bottom, against a series of patterns. The patterns are not regular expressions — they use the same glob-style matching that the shell uses for filename expansion, with wildcards like *, ?, and [...]. As soon as a pattern matches, Bash runs the commands associated with it and then, by default, jumps straight past every remaining pattern to esac (which is just case spelled backward — the keyword that closes the statement).
Internally, case is a Bash compound command, just like if or for. When the shell parses a script, it recognizes case ... esac as a single unit. At execution time, Bash expands the word exactly once (parameter expansion, command substitution, and arithmetic expansion all happen, but word splitting and pathname expansion do not apply to the word being tested, provided you quote it), then walks the pattern list. Each pattern is tested using the same matching engine as glob expansion in filenames — the same rules that let ls *.txt find every text file in a directory. If the extglob shell option is enabled, you even get extended pattern operators like +(pattern) and !(pattern), though the plain wildcards below cover the vast majority of real scripts.
The exit status of the whole case statement is the exit status of the last command that actually ran in the matched branch. If no pattern matches and there is no catch-all branch, case exits with status 0 and does nothing — which is itself a common source of silent bugs, covered in Common Mistakes below.
Syntax
The general shape of a case statement looks like this:
case <word> in
<pattern1>)
<commands>
;;
<pattern2> | <pattern3>)
<commands>
;;
*)
<default-commands>
;;
esac
<word>— the value being tested. Almost always a quoted variable, e.g.\"$choice\".<patternN>)— a glob pattern followed by a closing parenthesis. You can list several alternative patterns separated by|(logical OR) inside a single item.<commands>— one or more statements to run when the pattern matches.;;— ends the item and skips toesac. This is the terminator you will use 95% of the time.*)— a catch-all pattern (matches anything), conventionally placed last as a default case.esac— closes the statement.
The table below lists every pattern operator and terminator you will use in real scripts:
| Token | Meaning | Example |
|---|---|---|
* |
Matches any string, including an empty one | *.log matches any name ending in .log |
? |
Matches exactly one character | ??.txt matches a two-character name plus .txt |
[...] |
Matches one character from a set or range | [0-9]* matches anything starting with a digit |
| |
Separates alternative patterns (OR) inside one item | y|Y|yes) matches any of the three |
;; |
Ends an item; jumps directly to esac |
the default, normal terminator |
;& |
Ends an item but falls through and runs the next item’s commands unconditionally, without testing its pattern | used to chain shared behavior |
;;& |
Ends an item but keeps testing the remaining patterns instead of stopping | lets more than one pattern match the same word |
Examples
Example 1: Matching a day of the week
#!/usr/bin/env bash
day=\"Wed\"
case \"$day\" in
Mon|Tue|Wed|Thu|Fri)
echo \"Weekday\"
;;
Sat|Sun)
echo \"Weekend\"
;;
*)
echo \"Unknown day: $day\"
;;
esac
Output:
Weekday
The word \"$day\" is compared against each pattern in order. Wed matches the alternation Mon|Tue|Wed|Thu|Fri, so Bash prints Weekday and jumps to esac, never evaluating the Sat|Sun or *) branches.
Example 2: Classifying a file by extension
#!/usr/bin/env bash
file=\"backup.tar.gz\"
case \"$file\" in
*.tar.gz|*.tgz)
echo \"Compressed tarball\"
;;
*.txt)
echo \"Plain text file\"
;;
*.sh)
echo \"Shell script\"
;;
*)
echo \"Unknown file type: $file\"
;;
esac
Output:
Compressed tarball
This is where glob patterns shine: *.tar.gz matches any string ending in that suffix, regardless of what comes before it. This is far more concise than writing several [[ \"$file\" == *.tar.gz ]] comparisons chained with elif.
Example 3: A service-control script
#!/usr/bin/env bash
set -euo pipefail
action=\"${1:-}\"
case \"$action\" in
start)
echo \"Starting service...\"
;;
stop)
echo \"Stopping service...\"
;;
restart)
echo \"Restarting service...\"
;;
status)
echo \"Checking service status...\"
;;
-h|--help)
echo \"Usage: $0 {start|stop|restart|status}\"
;;
\"\")
echo \"Error: no action specified\" >&2
echo \"Usage: $0 {start|stop|restart|status}\" >&2
exit 1
;;
*)
echo \"Error: unknown action '$action'\" >&2
exit 1
;;
esac
Output:
$ ./service.sh start
Starting service...
$ ./service.sh
Error: no action specified
Usage: ./service.sh {start|stop|restart|status}
This mirrors how many real command-line tools (like systemctl or an init script) parse their first argument. Note the \"\") pattern, which matches an empty string — that is what catches the case where $1 was never supplied, thanks to \"${1:-}\" defaulting it to an empty string instead of triggering an unbound-variable error under set -u.
How It Works Step by Step
When Bash executes a case statement, it performs these steps in order:
- Expand
<word>exactly once (parameter, command, and arithmetic expansion happen; word splitting does not, as long as you quote it). - Compare the expanded word against the first pattern using glob matching rules.
- If it does not match, move to the next pattern and repeat.
- On the first match, execute the associated commands.
- When a
;;is reached, stop entirely and continue afteresac.
The two less common terminators change step 5. ;& (single semicolon-ampersand) falls through into the very next item’s commands without testing its pattern at all:
#!/usr/bin/env bash
grade=\"A\"
case \"$grade\" in
A)
echo \"Excellent\"
;&
B)
echo \"Good\"
;;
C)
echo \"Average\"
;;
*)
echo \"No grade\"
;;
esac
Output:
Excellent
Good
Even though $grade is A, both the A) and B) blocks run, because ;& forces execution to continue into the next block unconditionally.
;;& (double semicolon-ampersand) is different: it stops the current block but resumes testing the remaining patterns, rather than blindly running the next one:
#!/usr/bin/env bash
value=\"15\"
case \"$value\" in
[0-9]*)
echo \"Starts with a digit\"
;;&
1?)
echo \"Two-digit number starting with 1\"
;;
*)
echo \"No match\"
;;
esac
Output:
Starts with a digit
Two-digit number starting with 1
Here 15 matches [0-9]* first, and because of ;;&, Bash keeps checking the remaining patterns instead of exiting — it then also matches 1? (one digit, 1, followed by any single character). Both blocks run, but the *) catch-all is skipped because a real match was already found for that later test.
Common Mistakes
1. Forgetting the ;; terminator
Every case item must end in ;; (or ;&/;;&). Omitting it produces a syntax error, because Bash tries to parse the next pattern’s label as part of the previous command list.
#!/usr/bin/env bash
fruit=\"banana\"
case \"$fruit\" in
apple)
echo \"It's an apple\"
banana)
echo \"It's a banana\"
;;
esac
Bash reports a syntax error near banana) because the missing ;; after the apple) block leaves banana) looking like an attempted command. The fix is to always close every branch:
#!/usr/bin/env bash
fruit=\"banana\"
case \"$fruit\" in
apple)
echo \"It's an apple\"
;;
banana)
echo \"It's a banana\"
;;
esac
2. Leaving the tested word unquoted
case expects exactly one word before in. If the variable being tested contains spaces and is left unquoted, word splitting can turn it into multiple words, which Bash rejects.
#!/usr/bin/env bash
filename=\"monthly report.txt\"
case $filename in
*.txt)
echo \"Text file\"
;;
*)
echo \"Other file\"
;;
esac
Because $filename is unquoted, it splits into monthly and report.txt at runtime, and Bash raises a \”too many arguments\” style syntax error instead of matching the intended pattern. Always quote the word:
#!/usr/bin/env bash
filename=\"monthly report.txt\"
case \"$filename\" in
*.txt)
echo \"Text file\"
;;
*)
echo \"Other file\"
;;
esac
3. Omitting the catch-all *) branch
Without a default pattern, unexpected input is silently ignored — the script keeps running as if nothing happened, which hides bugs.
#!/usr/bin/env bash
color=\"purple\"
case \"$color\" in
red)
echo \"Stop\"
;;
green)
echo \"Go\"
;;
yellow)
echo \"Caution\"
;;
esac
echo \"Done processing color\"
With color=\"purple\", none of the three patterns match, nothing is printed for the color, and the script silently continues to \”Done processing color\” as though everything succeeded. Add a default branch that fails loudly instead:
#!/usr/bin/env bash
color=\"purple\"
case \"$color\" in
red)
echo \"Stop\"
;;
green)
echo \"Go\"
;;
yellow)
echo \"Caution\"
;;
*)
echo \"Unknown color: $color\" >&2
exit 1
;;
esac
echo \"Done processing color\"
Best Practices
- Always quote the tested word:
case \"$var\" in, nevercase $var in. - Always include a
*)catch-all branch, even if it just prints an error and exits — silent no-ops hide bugs. - Prefer
caseover a longif/elifchain whenever you are comparing one value against several fixed strings or patterns; it is easier to read and to extend. - Order patterns from most specific to least specific — the first match wins (unless you deliberately use
;;&). - Group related values with
|(e.g.y|Y|yes)) instead of duplicating a branch. - Reserve
;∧;&for cases that genuinely need fallthrough behavior, and add a comment explaining why — they are easy to miss when reading a diff. - Indent each pattern and its commands consistently; case blocks get hard to read fast when formatting is inconsistent.
- Remember patterns are glob syntax, not regular expressions —
[0-9]+does not mean \”one or more digits\” the way it would in regex.
Practice Exercises
- Menu selector: Write a script that reads a single character from the user with
read(options1through4) and prints a different action for each, plus an error message for anything else. Hint: useread -r choicebefore yourcase. - File classifier: Write a script that takes a filename as
$1and printsImagefor.jpg/.png/.gif,Videofor.mp4/.mkv, andUnknownotherwise. Make sure$1is quoted and handle the case where no argument was given. - Extend service.sh: Add a
reloadaction to the Example 3 script that should behave exactly likerestart. Decide whether to implement it with the|alternation operator (restart|reload)) or with;&fallthrough, and think through why one is a cleaner fit than the other here.
Summary
casematches one word against a list of glob-style patterns, running the commands for the first match.- The general form is
case word in pattern) commands ;; ... esac. - Patterns support
*(any string),?(one character),[...](a character set), and|to combine alternatives in one item. ;;stops after a match;;&falls through to the next block unconditionally;;;&keeps testing remaining patterns.- Always quote the tested word and always include a
*)default branch. caseis usually more readable than an equivalent chain ofif/elifcomparisons when you are matching one value against many options.
